[緊急解説] Trigger.devに潜むクロステナントSQLインジェクションの脅威 ([GHSA-9q4r-4842-93vw])
はじめに:なぜフロントエンドエンジニアがこの脆弱性を知るべきか
皆さん、こんにちは!普段フロントエンド開発に従事されている皆さんにとって、バックエンドやデータベースの脆弱性は一見縁遠いものに感じるかもしれません。しかし、私たちが日々利用するAPIやツールの背後には、様々な技術が組み合わされています。今回解説するTrigger.devにおけるクロステナントSQLインジェクションは、まさにその境界線に潜む脅威であり、システムのセキュリティ全体を理解する上で非常に重要な教訓を含んでいます。
この脆弱性は、TypeScriptで書かれたコードがどのようにSQLインジェクションを引き起こし、多テナント環境のセキュリティを破るかを示しています。コードレベルでの防衛策、安全なクエリ構築の重要性、そしてサードパーティ製ツールの利用における注意点など、フロントエンドエンジニアにとっても学ぶべき点が多々あります。それでは、詳細を見ていきましょう。
脆弱性の概要:GHSA-9q4r-4842-93vw
今回明らかになった脆弱性「GHSA-9q4r-4842-93vw」は、開発者向けのワークフロー自動化プラットフォームであるTrigger.devのTSQLクエリコンパイラに存在します。深刻度は「High」とされており、その影響は甚大です。
具体的には、認証済みのTrigger.devユーザーが`POST /api/v1/query`エンドポイントに対して特別に細工されたTSQLクエリを送信することで、他のテナントが所有する分析データを不正に読み取ることができてしまうというものです。これは多テナント型サービスにおいて、最も危険なタイプの脆弱性の一つと言えるでしょう。
Trigger.devとは?
Trigger.devは、開発者がTypeScriptコードを使ってバックグラウンドジョブ、ワークフロー、タスクを構築・実行できるようにするプラットフォームです。イベント駆動型のアプローチで、様々なAPIやサービスと連携した自動化フローを簡単に構築できるのが特徴です。バックエンドのロジックやデータベースとの連携も行うため、今回の脆弱性はまさにその中核部分に関わる問題でした。
技術的な深掘り:なぜSQLインジェクションが発生したのか
この脆弱性の核心は、TSQLクエリをClickHouse SQLにコンパイルする際の処理にありました。特に、`internal-packages/tsql/src/query/printer.ts`の`ClickHousePrinter.visitWindowFunction`メソッドで問題が発生します。
当該箇所では、ウィンドウ関数の名前(`node.name`)が、何のサニタイズやエスケープ処理も行われないまま、直接SQL文字列に連結されていました。
```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
...
}
```
他の多くの箇所では、関数名が許可リストでチェックされたり、文字列定数がパラメーター化されたり、識別子(テーブル名やカラム名など)が適切にエスケープされる機構が存在していました。しかし、**ウィンドウ関数名だけが、このセーフティネットから漏れてしまっていた**のです。
攻撃者はこの抜け穴を悪用し、ClickHouseが特殊な識別子として認識する「バッククォート(`)で囲まれた識別子」を利用しました。通常、バッククォート内の文字列は識別子として扱われますが、Lexerによってバッククォートが剥がされ、内部の文字列がそのまま`node.name`として解釈されます。これにより、本来関数名として想定されていない空白、括弧、コンマ、引用符、さらには**完全なサブクエリ**といった任意の文字が、「関数名」としてSQLに直接挿入されることを可能にしました。
Trigger.devのような多テナントシステムでは、通常、各テナントのデータを厳密に分離するためのメカニズムが導入されています。Trigger.devでは、`enforcedWhereClause`という仕組みで、APIキーから取得した`organization_id`、`project_id`、`environment_id`を外部クエリの`WHERE`句に追加することで、テナント分離を実現していました。
しかし、攻撃者がインジェクトしたサブクエリは、この**外部クエリのガードの範囲外**で実行されます。したがって、インジェクションされたサブクエリは、テナント分離の制約を受けずに、他のテナントのデータを自由に読み取ることができてしまったのです。
`POST /api/v1/query`エンドポイントは、`body.query`として未加工の`z.string()`を受け入れていました。また、認証は、有効なAPIキーやJWTを持つサインアップ済みの顧客であれば誰でも可能でした。
このルートの認可ロジック(`detectTables(body.query)` + `everyResource`)は、**クエリのFROM句で指定された外部テーブルのみ**をチェックし、それが呼び出し元がアクセスを許可されているテーブルであるかを確認します。しかし、今回のSQLインジェクションは`SELECT`句やウィンドウ関数名の位置に仕込まれるため、この認可チェックをすり抜けてしまいます。
攻撃手法(PoC)の解説
具体的な攻撃は、以下の2段階で実行されます。まず、悪意のあるTSQLがコンパイラによって処理され、次にその結果生成された危険なClickHouse SQLがデータベースで実行されます。
攻撃者は、`POST /api/v1/query`に対して、以下のようなTSQLクエリを送信します(ここでは`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
```
注目すべきは、`count() OVER ()`の後に続くバッククォートで囲まれた部分です。ここには、`org_OTHER_TENANT`(他のテナント)の`task_runs_v2`テーブルから`payload`を抜き取るサブクエリが巧妙に仕込まれています。
このTSQLがTrigger.devのコンパイラによって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
```
ご覧の通り、`stolen`エイリアスの部分に、`org_OTHER_TENANT`に対するサブクエリが何の保護もなく埋め込まれています。そして、テナントガードは外部の`task_runs_v2`テーブルにしか適用されていないため、注入されたサブクエリは自由に実行されます。
このコンパイルされたSQLが、ClickHouseデータベースで実行されると、`org_tenant1`として認証された攻撃者が、`org_OTHER_TENANT`の秘密データ(例: `VICTIM-SECRET-stripe_sk_live_DEADBEEF`、`VICTIM-SECRET-db_password_hunter2`など)を、自身のテナントデータと共に読み取ることができてしまいます。これにより、クロステナントSQLインジェクションが完全に実証された形です。
フロントエンドエンジニアへの教訓と対策
この脆弱性はバックエンドのコンパイラで発生したものですが、フロントエンドエンジニアにとっても重要な示唆を含んでいます。
フロントエンドでユーザーから受け取ったデータは、APIを通じてバックエンドに送信されます。常に「ユーザー入力は信頼できない」という原則に基づき、厳格な入力バリデーションを行いましょう。今回はバックエンドでのサニタイズ不足が問題でしたが、フロントエンドでも不正なデータを送らせないためのクライアントサイドバリデーションは重要です。
データベースクエリを直接文字列結合で生成するのではなく、安全なORマッパー(Prisma, TypeORM, Drizzle ORMなど)やクエリビルダーを積極的に利用しましょう。これらは通常、プリペアドステートメントを内部的に使用し、SQLインジェクションを自動的に防いでくれます。しかし、それらが提供するrawクエリ機能を使う場合は、今回のように注意が必要です。
Trigger.devのような開発ツールやライブラリは、私たちの開発を加速させますが、同時にそのセキュリティに依存することになります。利用するサードパーティ製ライブラリやフレームワークの脆弱性情報を常にチェックし、最新の状態に保つことが不可欠です。GitHub Advisory Databaseやnpm auditなどを活用しましょう。
今回の脆弱性は、入力検証、識別子のエスケープ、テナント分離のガード、そして認可チェックといった多層的な防御機構のうち、一部が欠落していたために発生しました。フロントエンドとバックエンドの境界線においても、それぞれのレイヤーで適切なセキュリティ対策が施されているか、全体像を意識することが重要です。
TypeScriptはコードの型安全性を高め、多くのエラーを防ぎますが、SQLインジェクションのようなロジック上の脆弱性までは防ぎきれません。型安全なクエリビルダーと組み合わせることで、より堅牢なシステムを構築できます。
まとめ
Trigger.devにおけるクロステナントSQLインジェクションは、多テナント型サービスにおけるデータ分離の重要性と、ユーザー入力に対する徹底したセキュリティ対策の必要性を改めて浮き彫りにしました。直接バックエンドのコードを書かないフロントエンドエンジニアであっても、このような脆弱性のメカニズムを理解しておくことは、より安全で堅牢なアプリケーションを開発するために不可欠です。
常に学び続け、セキュリティを意識した開発プラクティスを心がけていきましょう。この解説が、皆さんの日々の開発業務の一助となれば幸いです。