PraisonAI: Jobs APIはデフォルトでは認証なしで、攻撃者によって制御されるWebhook SSRFを許可する
## 概要
PraisonAIのAsync Jobs APIは、`PRAISONAI_JOBS_API_KEY`が設定されている場合にのみ、APIキーのミドルウェアを有効にします。そのため、デフォルトではすべてのエンドポイントが認証なしの状態です。認証されていない`POST /api/v1/runs`エンドポイントは、攻撃者によって制御される`webhook_url`を受け入れます。ジョブが完了すると、サーバーは`httpx`を使用して、ジョブのペイロードをこのURLにPOSTします。`webhook_url`にはSSRFの検証機能(`gethostbyname`とプライベートIPアドレスのチェック)がありますが、これはリクエスト時に検証されますが、`httpx`は接続時に再度解決するため、DNSリバインディングによるTOCTOU(Time-of-Check to Time-of-Use)が発生し、内部サービスにアクセスできてしまいます。実行時に確認されたのは、**認証なしで、ブラインド型の**SSRF(内部のカナリアサーバーがPOSTを受け取りましたが、内部からの応答は攻撃者に返されませんでした)。深刻度は中程度。
## 詳細
### 影響を受けるコンポーネント
- パッケージ: `praisonai` 4.6.63。ファイル: `src/praisonai/praisonai/jobs/server.py`, `jobs/router.py`, `jobs/executor.py`, `jobs/models.py`.
### 脆弱なコード / 根本原因
パス:
`src/praisonai/praisonai/jobs/server.py`
関数:
`create_app`
コードスニペット:
```python
jobs_api_key = os.environ.get("PRAISONAI_JOBS_API_KEY")
# ...
if jobs_api_key:
app.add_middleware(JobsAPIKeyMiddleware) # 環境変数が設定されている場合のみ認証
```
問題点: 環境変数が設定されていない(デフォルト)場合、認証ミドルウェアは追加されません → すべてのエンドポイントが認証なしの状態になります。デフォルトのバインドアドレスは`127.0.0.1`です。
パス:
`src/praisonai/praisonai/jobs/router.py`
関数:
`submit_job`
コードスニペット:
```python
@router.post("", response_model=JobSubmitResponse, status_code=202)
async def submit_job(request, response, body: JobSubmitRequest, ...):
# 認証の依存関係なし; body.webhook_urlは攻撃者によって制御される
job = Job(prompt=body.prompt, webhook_url=body.webhook_url, ...)
await executor.submit(job)
```
問題点: 攻撃者によって制御される`webhook_url`が、エンドポイントの認証なしでジョブに渡されます。
パス:
`src/praisonai/praisonai/jobs/models.py` (バリデータ) および `src/praisonai/praisonai/jobs/executor.py` (実行エンジン)
関数:
`validate_webhook_url` → `_send_webhook`
コードスニペット:
```python
# models.py validate_webhook_url (チェック時)
ip = socket.gethostbyname(hostname)
if ipaddress.ip_address(ip).is_private or ...:
raise ValueError("Webhook URL がプライベートネットワークまたは制限されたネットワークアドレスに解決されます")
# executor.py _send_webhook (接続時、再解決、ピン留めなし)
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(job.webhook_url, json=payload, ...)
```
問題点: バリデータは検証時にホスト名を解決しますが、`httpx.post` 接続のために IP アドレスを固定しません。これにより、DNS リバインディング (チェック時と接続時の独立した解決) が回避され、内部サービスにアクセスできるようになります。
### 攻撃の流れ
1. 攻撃者は `PRAISONAI_JOBS_API_KEY` を設定せずに Jobs API を実行します (デフォルトでは認証なし)。
2. 攻撃者は `webhook_url` をリバインディングされたドメインに設定して `POST /api/v1/runs` を送信します。
3. バリデータはパブリック IP アドレスを検出し、検証を通過します。ジョブの完了時に `httpx.post` が再度ホスト名を解決すると、内部 IP アドレスに解決され、接続が行われ、SSRF が発生し、内部サービスにアクセスできるようになります。
### 既存の保護が回避される理由
認証はオプションであり、環境変数が設定された場合にのみ有効になります。`webhook_url` バリデータは検証時にホスト名を解決しますが、接続のために IP アドレスを固定しないため、DNS リバインディングによる TOCTOU (Time-of-Check-Time-of-Use) の脆弱性が存在します。(以前の静的解析の注釈の訂正: 対策は存在しますが、回避可能です。)
### セキュリティ境界
認証されていないネットワークピア → サーバー側の内部サービスへのリクエスト (範囲: 変更)。デフォルトのバインドアドレスは `127.0.0.1` であり、オペレーターがループバック以外の IP アドレスにバインドしない限り、リモートアクセスは制限されます。
## 実証
### 環境
ローカル環境 (`127.0.0.1:18085`) で実行される Jobs API (`python -m praisonai.jobs.server`、API キーなし)。DNS は攻撃者が制御するリバインディングドメインを指し、内部の Canary コンテナは内部ネットワークでのみアクセス可能です。実行可能なファイルは `PraisonAI-Runtime-Repro\runtime-files\` にあります (`docker-compose.jobs.yml`)。
### 再現手順
1. `PRAI-04-01-Jobs-Submit-NoAuth`: `POST /api/v1/runs {"prompt":"hello"}` → `202` (認証なし)。
2. `PRAI-04-02-Jobs-Webhook-SSRF-Blind`: `POST /api/v1/runs {"prompt":"hello","webhook_url":"http://rebind.lab:8081/secret"}` → `202`。
### 期待される結果
認証なしでのジョブの送信は拒否されるべきです。また、WebhookのSSRFガードは、DNSのタイミングに関わらず、内部サービスへのアクセスを防止するべきです。
## 影響
認証なしでのジョブ送信(LLMのコストの不正利用)、内部サービスへのSSRF攻撃(ブラインド、DNSリバインディング)、ジョブの結果を攻撃者が制御するWebhookに送信。
## 推奨される対策
- Jobs APIをデフォルトで認証する(トークンを自動生成する/非ループバック接続の場合はエラーにする)、ゲートウェイと同様にする。
- `webhook_url`について:一度解決し、プライベート/ループバック/CGNAT/メタデータへのアクセスを拒否し、検証済みのIPアドレスに固定して接続する。リダイレクトを無効にする。
影響を受けるパッケージ:
- pip (Python) praisonai: 影響を受けるバージョンは4.6.77以下、4.6.78で修正済み。