All vulnerabilities
GHSA-gvr5-22f4-pj6g

openpanel: sankey.service.ts escapes ClickHouse strings by doubling quotes only, so a backslash in an event name terminates the literal early

openpanel
CVSS
5.3Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published11 Oct 2026Updated 11 Oct 2026GitHub

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 equivalent

So 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