Skip to content

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

  1. A WebSocket route family is a second route registration — test it separately. The /live/visitors/:projectId live-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.
  2. 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 appending OR 1=1; comment-terminated payloads swallowing the trailing project_id scope clause; UNION and boolean count() 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.
  3. Identifier fields are expression slots. Column names (profile filter names), the property picker in profile.values, and chart field selectors let the caller name or construct SQL fragments — the property leg accepts a whole SELECT ... FROM ... WHERE with 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, try a, a), `a`, a --, a FROM system.tables --, and (SELECT ...).
  4. 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 member may pass from share-link-holder.
  5. 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 from notification.rules reading 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 the client.list leg 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.
  6. Authorization by row-existence and by guessable slug is authorization theater. event.bots asked "does some shareOverview row exist?" instead of "may this caller see this project?" and the public reference.getChartReferences trusted 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.project proves the sibling check: verify mutation verbs enforce the role the UI implies; UI-hidden admin actions callable by members are the standing test.
  7. 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.
  8. 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.
  9. 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 call Function — 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 kobako public_send fold on the Oct 1 vm2 page): (x)=>x reflection chains, .constructor walks, 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

  1. 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.
  2. 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 against system.one; record response-shape deltas (extra marker rows, count changes, parse errors = injection surface, cf. the sankey availability leg proving the parse boundary).
  3. 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".
  4. Validator battery: lab project, one worker, marker-only payload (String.fromCharCode proof or file marker inside the worker container). Never escape into the host.
  5. Sidecar check: confirm the dashboard port is separately exposed (docker-compose ports / ingress config in lab; banner-grab externally only where authorized).