{"schema":"threatline.record/2","record":{"id":"GHSA-gvr5-22f4-pj6g","aliases":["GHSA-gvr5-22f4-pj6g"],"summary":"sankey.service.ts escapes ClickHouse strings by doubling quotes only, so a backslash in an event name terminates the literal early","details":"## Summary\r\n\r\n`sankey.service.ts` is the only file in `packages/db/src` that escapes values for ClickHouse by hand:\r\n`name.replace(/'/g, \"''\")`, in **8 places**. Everywhere else in the same package uses\r\n`sqlstring.escape` (260 call sites). Quote-doubling alone is not sufficient for ClickHouse, because\r\nClickHouse also honours backslash escapes inside single-quoted strings — so `\\'` in an\r\nattacker-supplied event name terminates the literal early.\r\n\r\n**I want to be precise about what this is and is not.** I could break out of the string literal, but\r\nI could **not** turn that into a working injection in these templates, and I believe it is structurally\r\nimpossible there — see \"Why this is not SQL injection\" below. What an authenticated project member\r\ncan do is make the Sankey query fail to parse, i.e. break the chart endpoint with a crafted event\r\nname. I am filing it as Low on that basis rather than dressing it up as a third ClickHouse injection.\r\n\r\n## Affected\r\n\r\n`packages/db/src/services/sankey.service.ts` lines 61, 67, 105, 114, 127, 128, 175, 176 —\r\n`main` at `3060ca10213693cf0385be2713c8743d16733a2b` (2026-09-04). (Also `packages/db/scripts/\r\nfind-duplicate-events.ts:127` and `check-sessions.ts:154`, which are dev scripts.)\r\n\r\n## The escape is insufficient — measured, not assumed\r\n\r\nClickHouse 24.8.14.39, official image, queried over HTTP:\r\n\r\n```\r\nSELECT length('a\\'b')            ->  3        # \\' is ONE character: the quote is escaped\r\nSELECT 'a\\'b' = 'a''b'           ->  1        # backslash-escape and doubled-quote are equivalent\r\n```\r\n\r\nSo a name containing `\\'` produces, after `replace(/'/g, \"''\")`, a sequence that closes the literal\r\none quote earlier than intended. Differential against correct escaping, same input, same server:\r\n\r\n```\r\nname = \"nomatch\\' OR 1=1 OR '\"\r\n\r\nvendor escape :  SELECT arrayExists(x -> x = 'nomatch\\'' OR 1=1 OR ''', ['purchase'])\r\n                 -> Code: 62. Syntax error … Single quoted string is not closed\r\n\r\nsqlstring-style:  SELECT arrayExists(x -> x = 'nomatch\\\\\\' OR 1=1 OR \\'', ['purchase'])\r\n                 -> 0        (parses; the payload is inert data)\r\n```\r\n\r\n`sqlstring.escape` escapes the backslash as well, which is exactly the difference.\r\n\r\n## How an attacker reaches it\r\n\r\n`chart.sankey` (`packages/trpc/src/routers/chart.ts:603`) is a `protectedProcedure` taking\r\n`zReportInput`; the caller supplies the report definition, including `events[].name`. That flows to\r\n`sankeyService.getSankey(...)` → `startEvent.name` / `endEvent.name` → the hand-rolled escape at\r\nlines 105/114/127/128/175/176, and through `buildEventNameFilter` at 61/67. No validation strips\r\nbackslashes on the way.\r\n\r\nSo any member of any project — read access is enough, since `enforceAccess` only requires `read` for\r\na query — can make that project's Sankey chart return a ClickHouse parse error instead of a chart.\r\n\r\n## Why this is not SQL injection (the part I could not complete)\r\n\r\nEvery `'` an attacker puts in the name is doubled, so the payload can never emit an *odd* number of\r\nquotes. The template supplies exactly one closing quote, and `\\'` consumes it. After the break-out\r\nthere is always exactly one dangling quote that cannot be balanced:\r\n\r\n* ending the payload with `--` comments out the rest of the single-line query, leaving\r\n  `arrayExists(`/`IN (` unterminated;\r\n* in the `IN ('a', 'b')` shape at line 61, the dangling quote swallows the `, ` separator, so the two\r\n  string literals end up adjacent with no comma between them — also a parse error.\r\n\r\nI tried both shapes against a real ClickHouse with several payload constructions and every one ended\r\nin `Code: 62 … Syntax error`. I am reporting the escaping defect and the parse break, not a\r\ncross-tenant read, because I could not demonstrate one and I would rather under-claim.\r\n\r\nIf you know of a call path where this string lands somewhere with a different quote parity — a\r\nmulti-line query, or a context that supplies a trailing quote — the conclusion above would not hold\r\nthere, and it would become the same class as GHSA-hwf2-2v2h-7892 / GHSA-cc5p-97vc-8fwv.\r\n\r\n## Severity\r\n\r\n**Low** — `CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N`. Availability of one\r\nchart endpoint, by an authenticated member.\r\n\r\n## Suggested fix\r\n\r\nReplace all eight with the helper the rest of the package already uses:\r\n\r\n```ts\r\n-  const escapedStartEvent = startEvent.name.replace(/'/g, \"''\");\r\n-  `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`\r\n+  `arrayExists(x -> x = ${sqlstring.escape(startEvent.name)}, events_deduped)`\r\n```\r\n\r\n(`sqlstring.escape` emits its own quotes, so the surrounding `'…'` come out.) Likewise at 61/67:\r\n`.map((e) => sqlstring.escape(e!))`.\r\n\r\nA grep that keeps this from coming back:\r\n`grep -rn \"replace(/'/g\" packages/db/src` should return nothing.\r\n\r\n## Disclosure\r\n\r\nNothing is disclosed on my side and nothing will be until you publish or tell me otherwise. This is\r\nseparate from `GHSA-vrrm-p9p4-2gfg`, which I filed earlier today on `reference.getChartReferences`.\r\n","categories":["Unclassified"],"product":"openpanel","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-gvr5-22f4-pj6g","source_modified_at":"2026-10-11T20:58:11Z","fetched_at":"2026-10-26T23:45:56.352924Z","package":{"ecosystem":"openpanel-dev","name":"openpanel"},"range":"<= 42fc8623b5c5c30f0b89686451c0c5ab13046b81"}],"fixed_versions":[],"cvss":5.3,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-11T20:58:11Z","modified_at":"2026-10-11T20:58:11Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/Openpanel-dev/openpanel/security/advisories/GHSA-gvr5-22f4-pj6g","fetched_at":"2026-10-26T23:45:56.352924Z","source_modified_at":"2026-10-11T20:58:11Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-11T20:58:11Z","content_hash":"464d8f01ea08d9022660b2c3f87e5786a20d3a4873e1bd2ae650165977964371"}}
