openpanel: sankey.service.ts escapes ClickHouse strings by doubling quotes only, so a backslash in an event name terminates the literal early
What fixes it
No source in this snapshot names a fixed version.
Description
Summary
sankey.service.ts is the only file in packages/db/src that escapes values for ClickHouse by hand: name.replace(/'/g, "''"), in 8 places. Everywhere else in the same package uses sqlstring.escape (260 call sites). Quote-doubling alone is not sufficient for ClickHouse, because ClickHouse also honours backslash escapes inside single-quoted strings — so \' in an attacker-supplied event name terminates the literal early.
I want to be precise about what this is and is not. I could break out of the string literal, but I could not turn that into a working injection in these templates, and I believe it is structurally impossible there — see "Why this is not SQL injection" below. What an authenticated project member can do is make the Sankey query fail to parse, i.e. break the chart endpoint with a crafted event name. I am filing it as Low on that basis rather than dressing it up as a third ClickHouse injection.
Affected
packages/db/src/services/sankey.service.ts lines 61, 67, 105, 114, 127, 128, 175, 176 — main at 3060ca10213693cf0385be2713c8743d16733a2b (2026-09-04). (Also packages/db/scripts/ find-duplicate-events.ts:127 and check-sessions.ts:154, which are dev scripts.)
The escape is insufficient — measured, not assumed
ClickHouse 24.8.14.39, official image, queried over HTTP:
SELECT length('a\'b') -> 3 # \' is ONE character: the quote is escaped
SELECT 'a\'b' = 'a''b' -> 1 # backslash-escape and doubled-quote are equivalentSo a name containing \' produces, after replace(/'/g, "''"), a sequence that closes the literal one quote earlier than intended. Differential against correct escaping, same input, same server:
name = "nomatch\' OR 1=1 OR '"
vendor escape : SELECT arrayExists(x -> x = 'nomatch\'' OR 1=1 OR ''', ['purchase'])
-> Code: 62. Syntax error … Single quoted string is not closed
sqlstring-style: SELECT arrayExists(x -> x = 'nomatch\\\' OR 1=1 OR \'', ['purchase'])
-> 0 (parses; the payload is inert data)sqlstring.escape escapes the backslash as well, which is exactly the difference.
How an attacker reaches it
chart.sankey (packages/trpc/src/routers/chart.ts:603) is a protectedProcedure taking zReportInput; the caller supplies the report definition, including events[].name. That flows to sankeyService.getSankey(...) → startEvent.name / endEvent.name → the hand-rolled escape at lines 105/114/127/128/175/176, and through buildEventNameFilter at 61/67. No validation strips backslashes on the way.
So any member of any project — read access is enough, since enforceAccess only requires read for a query — can make that project's Sankey chart return a ClickHouse parse error instead of a chart.
Why this is not SQL injection (the part I could not complete)
Every ' an attacker puts in the name is doubled, so the payload can never emit an *odd* number of quotes. The template supplies exactly one closing quote, and \' consumes it. After the break-out there is always exactly one dangling quote that cannot be balanced:
- ending the payload with
--comments out the rest of the single-line query, leaving
arrayExists(/IN ( unterminated;
- in the
IN ('a', 'b')shape at line 61, the dangling quote swallows the,separator, so the two
string literals end up adjacent with no comma between them — also a parse error.
I tried both shapes against a real ClickHouse with several payload constructions and every one ended in Code: 62 … Syntax error. I am reporting the escaping defect and the parse break, not a cross-tenant read, because I could not demonstrate one and I would rather under-claim.
If you know of a call path where this string lands somewhere with a different quote parity — a multi-line query, or a context that supplies a trailing quote — the conclusion above would not hold there, and it would become the same class as GHSA-hwf2-2v2h-7892 / GHSA-cc5p-97vc-8fwv.
Severity
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 chart endpoint, by an authenticated member.
Suggested fix
Replace all eight with the helper the rest of the package already uses:
- const escapedStartEvent = startEvent.name.replace(/'/g, "''");
- `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`
+ `arrayExists(x -> x = ${sqlstring.escape(startEvent.name)}, events_deduped)`(sqlstring.escape emits its own quotes, so the surrounding '…' come out.) Likewise at 61/67: .map((e) => sqlstring.escape(e!)).
A grep that keeps this from coming back: grep -rn "replace(/'/g" packages/db/src should return nothing.
Disclosure
Nothing is disclosed on my side and nothing will be until you publish or tell me otherwise. This is separate from GHSA-vrrm-p9p4-2gfg, which I filed earlier today on reference.getChartReferences.
Text from GitHub Advisory Database
Affected versions 1
- openpanel, openpanel-dev<= 42fc8623b5c5c30f0b89686451c0c5ab13046b81GitHub