PraisonAI: `web_crawl`におけるDNSリバインディングの回避により、内部からのレスポンスが外部に漏洩する

## 概要

PraisonAIの`web_crawl`エージェントツールは、エージェントまたは攻撃者の影響下にあるURLに対して、サーバー側のHTTPリクエストを行います。SSRF(サーバーサイドリクエストフォージェリ)は、`_is_safe_crawl_url()`によって防止されるはずですが、この関数はホスト名を解決し、検証時にプライベートIP、ループバックIP、リンクローカルIPを拒否します。検証されるのはURLの*文字列*であり、固定されたIPアドレスではありません。その後、フェッチのバックエンドが接続時にホスト名を*再度*解決します。検証と接続がそれぞれ独立したDNS解決を行うため、検証時にはパブリックIPアドレスを返し、フェッチ時には内部IPアドレスを返すような**DNSリバインディング**のドメイン名を使用すると、この防御を完全に回避し、内部のHTTPレスポンスの内容が呼び出し元に返される可能性があります。

これは、**内部レスポンスの開示を伴うSSRF(読み取り)**であり、ブラインドSSRFではありません。PraisonAI 4.6.63で動作確認済み。クロールされたレスポンスに、制御された内部のマーカー `PRAISONAI_INTERNAL_SECRET_CANARY_7f3a91` / `FAKE_INTERNAL_TOKEN_DO_NOT_USE_7f3a91` が含まれていました。重大度:高。エージェントがクロールするURLに影響を与えられるすべての攻撃者がこの脆弱性を利用できます(例:チャット/ボット/エージェントのインターフェース)。

## 詳細

### 影響を受けるコンポーネント
- パッケージ: `praisonaiagents` (PraisonAI)、バージョン **4.6.63**。
- ファイル: `src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py`; ツール `web_crawl` / `crawl_web` (デフォルトのボットツールセットの一部)。

### 脆弱なコード / 根本原因

**コードポイント1 — 検証時のみのDNS検証、IPアドレスの固定化なし**

パス:
`src/praisonai-agents/praisonaiagents/tools/web_crawl_tools.py`

関数:
`_is_safe_crawl_url`

以下に、提供された英語のテキストの日本語訳を示します。

スニペット:
```python
for info in socket.getaddrinfo(hostname, None): # チェック時に解決
ip = ipaddress.ip_address(info[4][0])
if (ip.is_loopback or ip.is_private or ip.is_link_local
or ip.is_multicast or ip.is_unspecified):
return False
return True
```
問題点:このチェック機構は、ホスト名を解決しますが、**チェック時に一度だけ**解決します。解決されたIPアドレスを固定せず、そのIPアドレスをHTTPクライアントに返したり転送したりしません。後続の解決結果が異なる可能性があります。

**コードポイント2:チェック機構が実行された後、URLの*文字列*がバックエンドに渡される**

関数:
`web_crawl`

スニペット:
```python
for u in raw_url_list:
if _is_safe_crawl_url(u): # URL文字列を検証
url_list.append(u)
...
results = _crawl_with_httpx(url_list) # または _crawl_with_crawl4ai(url_list)
```
問題点:攻撃者が制御する入力(`urls`)が文字列として検証されます。その後、バックエンドがその文字列を取得し、チェック機構とは**独立してDNSを再解決**します。チェックと取得の間に共有される、固定されたIPアドレスはありません。

**コードポイント3:`_crawl_with_httpx`バックエンドが再解決を行う(リダイレクトの再検証は、再バインディングを阻止できない)**

関数:
`_crawl_with_httpx`

スニペット:
```python
with httpx.Client(follow_redirects=False, timeout=30.0) as client:
for _ in range(max_redirects + 1):
if not _is_safe_crawl_url(current): # ホスト名を再解決(CHECK)
raise ValueError("Redirect target failed SSRF validation")
response = client.get(current) # 接続時に再度解決
```
問題点:ホップごとのリダイレクトの再検証を行っても、`_is_safe_crawl_url(current)`と`client.get(current)`は、**同じホスト名に対する2つの別個のDNS解決**です。リバインディングされたドメインが、チェック時にはパブリックなIPアドレスで応答し、接続時には内部のIPアドレスで応答するため、TOCTOU(Time-of-Check to Time-of-Use)の脆弱性が存在します。IPアドレスが固定されていません。

**コードポイント4:urllibの代替手段(同じ関数)、ホップごとのチェックなし**

コードスニペット:
```python
import urllib.request
with urllib.request.urlopen(url, timeout=30) as response: # 再度DNS解決を行い、自動的にリダイレクトを追跡
content = response.read().decode('utf-8', errors='ignore')
```
問題点:`httpx`がインストールされていない場合、`_crawl_with_httpx`内のこの代替処理は、URLを取得し、リダイレクトを自動的に追跡しますが、各接続に関する検証が行われません。(この関数からの結果は、実行されたパスに関わらず、常に `"provider": "httpx"` とラベル付けされます。)

**コードポイント5 — crawl4ai/Chromiumバックエンド(確認済み補足)**

crawl4aiバックエンド(`_crawl_with_crawl4ai` → `crawler.arun(url=url)`、ヘッドレスChromium)も、実行時に影響を受けていることが確認されました(ブラウザはDNSを再解決し、リダイレクトを追跡しますが、接続ごとの保護は行われません)。このレポートを`web_crawl`のSSRFガードに焦点を当てるため、バックエンド固有の証拠は`SSRF-04_Crawl4AI_SSRF_Backend_Addendum.md`に記載されています。

### 攻撃の流れ
1. 攻撃者は、権威あるDNSが再バインドされるホスト名(例:`rebind.lab`)を制御します。
2. 最初のルックアップ(ガード)→ 公開IP → `_is_safe_crawl_url()`がtrueを返します。
3. バックエンドが再度DNSを解決 → 攻撃者のDNSが、内部/プライベートIPアドレスに応答します(クラウドメタデータ、ループバック、内部サービス)。
4. バックエンドが内部サービスに接続し、その内容を呼び出し元に返します → 内部データの漏洩。

### 既存の保護が回避される理由
- ガードはホスト名を検証しますが、固定されたIPアドレスを検証するものではありません。チェックと接続は独立して行われるため、DNSの再バインド(TOCTOU)によって、すべてのバックエンドで回避されます。
- リダイレクトの再検証(`httpx`のパス)は、ホスト名を再チェックしますが、接続時に再度DNSを解決するため、再バインドを阻止できません。`urllib`の代替処理やcrawl4aiバックエンドには、接続ごとの保護が全くありません。

### セキュリティ境界
サーバーサイドのデータ取得機能は、攻撃者から公開されていない内部/ループバック/メタデータサービスにアクセスし、その内容を返します(CVSSスコープ:変更)。エージェントが攻撃者によって提供されたURLをクロールするように誘導できる場所であれば、どこでもアクセス可能です(脆弱性評価:L)。認証なしで単一のリクエストで`web_crawl`の読み出しにアクセスできる経路は、バージョン4.6.63では確認されませんでした(したがって、脆弱性評価:N / 深刻度は該当しません)。

## 概念実証

### 環境
ローカルのDocker環境で動作するPraisonAI 4.6.63; 制御された内部のカナリアサービス(Docker内部のみ、公開されていません)が、ダミーのマーカーを返します。制御されたDNSレスポンダが、`rebind.lab`に対するリバインディングを実装しています。パブリックホスト、実際のメタデータ、実際のシークレットは使用されていません。実行可能なファイルは、`PraisonAI-Runtime-Repro\runtime-files\`にあります。

### 再現手順
1. Burp Repeaterタブ `PRAI-05-01-DNS-Rebind-Trigger` → `127.0.0.1:18080`:
```http
POST /tool/web_crawl HTTP/1.1
Host: 127.0.0.1:18080
Content-Type: application/json

{"url":"http://rebind.lab:8081/secret"}
```
2. 送信します(`PRAI-05-02-DNS-Rebind-Secret-Readback`でレスポンスをキャプチャします)。送信時に「ブロックされた」エラーが返された場合は、リバインディングDNSが自動的にリセットされます(約3秒)ので、再送信してください。
3. リダイレクトのバリエーション:`PRAI-05-03-Redirect-Trigger` / `PRAI-05-04-Redirect-Secret-Readback`で、`{"url":"http://redirector:8082/redirect-to-internal"}`を送信します。

### 期待される結果
安全なSSRFガードは、DNSのタイミングやリダイレクトに関わらず、内部/プライベートIPアドレスに解決される宛先を拒否し、内部コンテンツを返しません。

### 実際の結果
HTTP 200が返され、クロール結果に内部のコンテンツが含まれています。主な証拠は、`provider: "httpx"`のバックエンドが、DNSリバインディングによって内部のカナリアサービスを返している点です。
```json
{"input_url":"http://rebind.lab:8081/secret",
"result":{"content":"{ ... \"secret\": \"PRAISONAI_INTERNAL_SECRET_CANARY_7f3a91\", \"token\": \"FAKE_INTERNAL_TOKEN_DO_NOT_USE_7f3a91\" ... }","provider":"httpx"}}
```
リダイレクトのバリエーションでも、同様の内部マーカーが、リダイレクトチェーンを通じて返されます(`provider: "httpx"`)。

### スクリーンショット

**DNSリバインディングによる読み出し**

攻撃者によって制御される `rebind.lab` というURLが、`web_crawl` によって受け入れられ、PraisonAIの応答には、内部のカナリア応答の内容が含まれています。

<img width="1543" height="785" alt="01-DNS-Rebind-Burp-Readback" src="https://github.com/user-attachments/assets/e86e95dd-3d3a-4bef-b1c6-cb897234a212" />

**DNSリバインディングの実行時における証拠**

実行時のログには、検証時 (`guard-pass`) に `rebind.lab` が許可された/公開されているIPアドレスに解決され、実際の取得時 (`fetch-hit`) には内部のDocker IPアドレスに解決されることが示されています。内部のカナリアは、PraisonAIコンテナから `GET /secret` リクエストを受け取ります。

<img width="1654" height="828" alt="02-DNS-Rebind-DNS-Log-And-Internal-Hit" src="https://github.com/user-attachments/assets/f1d28046-de34-48f0-9bee-bbe26e598d5f" />

**リダイレクトベースのSSRFによる情報漏洩**

攻撃者によって制御されるリダイレクトURLが、`web_crawl` によって受け入れられます。PraisonAIはリダイレクトに従い、内部のカナリア応答の内容を返します。この応答には `PRAISONAI_INTERNAL_SECRET_CANARY_7f3a91` が含まれています。

<img width="1540" height="772" alt="03-Redirect-Burp-Readback" src="https://github.com/user-attachments/assets/79eec159-4b9f-44be-a9eb-b15aba35ead8" />

**リダイレクトチェーンの実行時における証拠**

制御されたリダイレクトURLは、`302 -> http://internal-canary:8081/secret` を返し、内部のカナリアは `GET /secret` リクエストを受け取ります。これにより、サーバー側のクライアントがリダイレクトに従って内部ネットワークにアクセスしたことが確認されます。

<img width="1637" height="894" alt="04-Redirect-Internal-Hit-Log" src="https://github.com/user-attachments/assets/1c503e30-9d01-4d32-8658-497f755a969e" />

### 再現に必要なファイル

添付のアーカイブには、この問題を再現するために使用されたローカルのDockerランタイムが含まれています。このアーカイブには、実際の機密情報、実際のクラウドメタデータへのアクセス、またはサードパーティのAPIキーは含まれていません。

[PraisonAI-Runtime-Repro.zip](https://github.com/user-attachments/files/29142379/PraisonAI-Runtime-Repro.zip)

## 影響
内部/ループバック/クラウドメタデータエンドポイントに対するSSRF攻撃により、**内部HTTPレスポンスの内容が攻撃者に開示される**(読み取り)。プロジェクトのSSRF保護機能を、すべてのデータ取得バックエンドで回避する可能性があります。

## 推奨される対策
1. ホスト名を一度解決し、すべての返却されたレコードがプライベート/ループバック/リンクローカル/ULA/CGNAT/メタデータである場合、それらを拒否し、**検証済みの正確なIPアドレスに接続する**(固定する。元の`Host`ヘッダーを送信する)。HTTPクライアント/ブラウザによる再解決を許可しない。
2. 同じ検証とIPアドレスの固定を、すべてのバックエンド(httpx、urllibの代替手段、crawl4ai)およびすべてのリダイレクト先にも適用する。
3. 自動リダイレクト追跡を無効にする(または、各リダイレクト先を固定し、再検証する)。
4. IPv4マッピングされたIPv6アドレス、10進数/8進数/16進数のIPアドレス、およびCGNAT/非グローバル範囲を、安全でないものとして扱う。

影響を受けるパッケージ:
- pip (Python) praisonaiagents: 影響を受けるバージョンは1.6.77以下、1.6.78で修正済み。