OpenPanel fourteen-leg wave — WebSocket route-family authz gap, ClickHouse literal-scoping, redact-on-read drift, and an AST allowlist that never blocked constructor.constructor¶
Wave in one line: An open-source product analytics platform (< 3.0.0) published its own audit map: fourteen GHSAs at 2026-10-27T00:32Z covering an unauthenticated ClickHouse SQLi living on a WebSocket route the HTTP authz layer never saw, four more SQLi legs through identifier fields into a database with no parameter binding, plaintext integration secrets on a read path that skipped the redact call, an argon2-hashed credential leak enabling offline verification, a queue dashboard on a sidecar port, and a validator allowlist defeated by a two-hop prototype walk.
Source: hourly offensive-security scan of GitHub Security Advisories, wave published 2026-10-27T00:32Z, all legs "OpenPanel before 3.0.0".
OpenPanel is the reusable shape of any multi-tenant analytics/SaaS dashboard: Next.js + tRPC front plane, ClickHouse event store, Postgres config store, BullMQ worker with its own HTTP surface, and a user-code "js-runtime" sandbox. Every leg generalizes to that stack shape.
Advisory table¶
| Leg | GHSA / CVE | Sev | Boundary |
|---|---|---|---|
Unauth ClickHouse SQLi via projectId on the /live/visitors/:projectId WebSocket — no session or project-access check; UNION or boolean count oracles read other tenants' events/profiles | GHSA-wfxj-g749-hqjv / CVE-2026-116818 | high | Route-family gap: HTTP authz ≠ transport-parity authz |
SQLi through profile-filter column names (profileColumnAccess, cohort.service.ts) — unescaped name spliced into argMax/cityHash64/comparison clauses | GHSA-gjrj-qcgp-p97f / CVE-2026-116812 | high | Identifier context, project-read tier |
SQLi in ConversionService.getConversion through event names and startDate/endDate — literal breakout + OR 1=1 neutralizes the project_id scope; reachable by share-link holders | GHSA-4m7v-v664-jv5c / CVE-2026-116813 | high | Scoping-as-WHERE-literal |
profile.values property parameter accepts a full SELECT expression with attacker FROM/WHERE and a comment that swallows the project_id scope | GHSA-j23v-hgjh-xpmx / CVE-2026-116808 | high | Field-picker params are expression slots |
Profile-filter name injected into a JOIN subquery; trailing comment bypasses the project_id WHERE | GHSA-4c79-56xr-vg4w / CVE-2026-116807 | high | Same grammar, second entry point |
js-runtime validator AST allowlist bypass: alias payload.constructor.constructor into a local and call it → arbitrary code in the worker, project-member tier | GHSA-r85r-mvjp-f5x7 / CVE-2026-116811 | high | AST allowlist without prototype-walk check |
notification.rules tRPC query skips redactIntegration → plaintext Slack bot tokens, webhook URLs, authorization headers | GHSA-wqh4-2xq9-jpwc / CVE-2026-116819 | high | Redaction is a per-call-site convention |
client.list returns full Client rows including the secret column — argon2id hashes of ingestion credentials → offline verification of leaked/reused secrets with no audit trail | GHSA-5qgc-257q-crrq / CVE-2026-116805 | medium | Hash-of-a-secret is a secret |
onboarding.project mutation callable by non-admin org members → create projects + mint write-type ingestion credentials → inject fake events, poison metrics | GHSA-7qfj-9p8q-5fq8 / CVE-2026-116806 | medium | Admin-only policy enforced in UI only |
event.bots tRPC checks only shareOverview row existence → unauthenticated read of bot event names/types/paths for private and password-protected projects | GHSA-6gm2-h9rv-g3p8 / CVE-2026-116809 | medium | Row-existence ≠ authorization |
Public reference.getChartReferences dumps cross-tenant annotation titles/dates (release notes, deploy markers) via guessable slug project IDs | GHSA-74w8-8m6p-549q / CVE-2026-116810 | medium | Public-procedure default on guessable ID |
Bull Board dashboard unauthenticated on the worker HTTP port — /api/queues read plus mutating add/retry/clean job APIs | GHSA-gh2r-9hfj-2qvw / CVE-2026-116817 | medium | Sidecar port outside the app auth story |
Database connection URLs and password-reset tokens written to stdout logs (migration startup logs; reset tokens logged when SMTP_HOST/RESEND_API_KEY unset) | GHSA-3j3f-vrq7-3c85 / CVE-2026-116814 | high | Log read = credential read |
Backslash escaping gap in sankey.service.ts breaks ClickHouse literals on attacker event names → chart endpoint parse-error outage | GHSA-p3rg-2cmw-54f4 / CVE-2026-116815 | medium | Same injection surface, availability leg |
Authorized validation only
Use two disposable projects/tenants you own, synthetic events, fake integration tokens pointing at your own callbacks, and a lab instance. Do not read real tenants' analytics, capture live Slack tokens or webhook URLs, or touch the shared events table beyond your own project's synthetic rows.
Durable rules¶
- A WebSocket route family is a second route registration — test it separately. The
/live/visitors/:projectIdlive-updates socket carried neither the session check nor the project-access check that the equivalent HTTP/tRPC path enforced. For any SPA: extract the client bundle, grep the WebSocket constructor call sites (new WebSocket,io(,ws://,wss://), enumerate every upgrade path, and re-run the authz matrix you already ran against HTTP. The most valuable real-time endpoints (live visitor counters, notification feeds, collaborative cursors) are exactly the ones bolted on after the auth middleware existed. Same transport-parity family as the Oct 14 NGINX HTTP/3 smuggling leg — every new transport re-opens the auth question. - Analytics databases are usually reached without parameter binding, and tenant scoping is a WHERE string literal. All five SQLi legs share one grammar problem: ClickHouse driver calls interpolate values into SQL text, so
project_id = '...'is a sentence, not a predicate. Breakout shapes to replay on any ClickHouse/BigQuery-backed product: value fields (event names, dates) closing their own string literal and appendingOR 1=1; comment-terminated payloads swallowing the trailingproject_idscope clause; UNION and booleancount()oracles when the WebSocket path renders numbers. Ask per feature which tenant rows you must not see, then diff response shapes with a second tenant's synthetic marker event. - Identifier fields are expression slots. Column names (profile filter names), the
propertypicker inprofile.values, and chart field selectors let the caller name or construct SQL fragments — thepropertyleg accepts a wholeSELECT ... FROM ... WHEREwith your own tables. This is the same identifier-context rule the Oct 25 YesWiki wave proved with backtick-quoted aliases: value escapers do not protect identifier context, and field-picker UI parameters are the most-missed SQLi surface because they look like enums. Battery: for every parameter that ends up naming a column/field/grouping, trya,a),`a`,a --,a FROM system.tables --, and(SELECT ...). - Run every privilege tier, including the weird low one. The conversion-SQLi leg is reachable by share-link holders — the lowest principal the product even has. Standing rule: enumerate the principal ladder (owner / member / read-member / share-link-holder / anonymous) and replay each injection and authz probe at every rung; a leg that fails from
membermay pass fromshare-link-holder. - Redact-on-read is a call-site convention, not a column property.
redactIntegration()runs on the query path the settings UI uses and is simply absent fromnotification.rulesreading the same rows. Grep the app for the redaction helper, list every read path to the secrets column(s), and diff which paths call it. Same audit shape for masking/billing/log views. Corollary theclient.listleg teaches: a returned hash of a machine credential is a credential finding — argon2id of an ingestion key still enables offline verification of leaked/reused values with zero DB access, and the row came with no audit trail. - Authorization by row-existence and by guessable slug is authorization theater.
event.botsasked "does some shareOverview row exist?" instead of "may this caller see this project?" and the publicreference.getChartReferencestrusted the slug. On tRPC products: procedure names are a complete, extractable API inventory (client bundle ships the router types) — enumerate them and run a per-procedure authz decision table, positive and negative controls per tenant pair.onboarding.projectproves the sibling check: verify mutation verbs enforce the role the UI implies; UI-hidden admin actions callable by members are the standing test. - The worker's dashboard port is in scope. Bull Board (and Flower, Sidekiq Web UI, Celery Flower, Argo's UI sidecars) ships with its own unauthenticated HTTP port; app-level auth never covers it. Port-inventory rule: for every app with a job queue, scheduler, or workflow engine, port-scan for the sidecar dashboard and test
/api/queues-class mutating endpoints (add/retry/clean = tamper + resource spend) unauthenticated. - stdout is a secret store. Migration startup printed DB DSNs; password-reset tokens hit stdout whenever the email provider env vars were unset (a very common dev/staging shape). On any containerized target where you have log-view (CI output,
kubectl logs, platform log UIs), grep captured stdout for DSNs and token-shaped strings — and as a bug-hunt heuristic, trigger a password reset on a staging deploy with mail unconfigured. - AST allowlists without a prototype-walk rule are open doors. The js-runtime validator blocked named dangerous globals but let
payload.constructor.constructor(...)build and callFunction— two property hops from any bound object to code execution inside the worker. Battery for every user-code validator (JS, Python, Ruby — see the Oct 15 kobakopublic_sendfold on the Oct 1 vm2 page):(x)=>xreflection chains,.constructorwalks, string-built dispatchers, and ambient globals reachable from callbacks. Fix-shape fingerprint: does the checker denylist names (evadable) or allowlist syntax + reject member chains to constructors (closing)?
Replayable validation boundaries¶
- Route-family parity matrix: client-bundle route extraction → {HTTP, tRPC, WebSocket, sidecar port} × {anon, share-link, read-member, member} → positive control (your project's data appears) + negative control (second tenant's synthetic marker must not appear). The finding is the cell where HTTP denies and another family serves.
- Literal-scoping probes: seed project A and project B with distinct synthetic marker events; replay each filter/date/property parameter with
',' OR 1=1 --,x') --, and a UNION againstsystem.one; record response-shape deltas (extra marker rows, count changes, parse errors = injection surface, cf. the sankey availability leg proving the parse boundary). - Redact diff: call every settings/notifications/clients read procedure with a read-tier token; diff serialized rows for the secret fields against the UI's own render. Report per-path retention, not "secrets exposed".
- Validator battery: lab project, one worker, marker-only payload (
String.fromCharCodeproof or file marker inside the worker container). Never escape into the host. - Sidecar check: confirm the dashboard port is separately exposed (docker-compose ports / ingress config in lab; banner-grab externally only where authorized).
Related Skillz Wiki guidance¶
- Identifier-context SQLi twin (backtick-quoted aliases): Oct 25 YesWiki second-wave follow-up.
- Sidecar/secondary-port class: Oct 13 AIStore wave (legacy/experimental families skip the gate) and the unauthenticated gRPC updater on the Aug 3 updater page.
- Sandbox prototype-walk family: Oct 5 vm2 coordinated dump page and the Oct 15 kobako
public_sendfold on the Oct 1 vm2 sandbox-escape wave page. - Transport-parity authz rule: Oct 14 NGINX wave HTTP/3 leg.