From eec92a92c07cd1a6b9712b5d0a13e8198606ddc1 Mon Sep 17 00:00:00 2001 From: luyifan Date: Mon, 29 Jun 2026 22:36:06 +0800 Subject: [PATCH] Enforce WhatsApp Cloud webhook body limit while reading --- gateway/platforms/whatsapp_cloud.py | 26 ++++++++++++++++++++------ tests/gateway/test_whatsapp_cloud.py | 23 +++++++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/whatsapp_cloud.py b/gateway/platforms/whatsapp_cloud.py index 3a74d33ebb0..c2ff0268e45 100644 --- a/gateway/platforms/whatsapp_cloud.py +++ b/gateway/platforms/whatsapp_cloud.py @@ -90,6 +90,7 @@ DEFAULT_WEBHOOK_HOST = "0.0.0.0" DEFAULT_WEBHOOK_PORT = 8090 DEFAULT_WEBHOOK_PATH = "/whatsapp/webhook" GRAPH_API_BASE = "https://graph.facebook.com" +WEBHOOK_MAX_BODY_BYTES = 3 * 1024 * 1024 # Meta retries failed webhooks for up to 7 days. We don't need to remember # every wamid for the full retry window — the practical risk is duplicate # delivery within minutes, not days. 5000 entries with FIFO eviction is @@ -144,6 +145,17 @@ _WHATSAPP_MIME_EXTENSION_OVERRIDES: Dict[str, str] = { } +async def _read_limited_request_body(request: Any, max_bytes: int) -> bytes: + """Read at most ``max_bytes`` from an aiohttp request body.""" + try: + body = await request.content.readexactly(max_bytes + 1) + except asyncio.IncompleteReadError as exc: + body = exc.partial + if len(body) > max_bytes: + raise ValueError("payload too large") + return body + + def _ext_for_mime(mime: str) -> Optional[str]: """Resolve a mime type to the file extension we want on disk. @@ -1405,16 +1417,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter): multiply downstream agent work because of a transient bug during dispatch. """ + # Meta's documented max payload is 3MB. Read one byte past the limit + # so oversized chunked bodies are rejected before buffering the rest. try: - raw = await request.read() + raw = await _read_limited_request_body( + request, + WEBHOOK_MAX_BODY_BYTES, + ) + except ValueError: + return web.Response(status=413) except Exception: return web.Response(status=400) - # Meta's documented max payload is 3MB. Reject earlier than aiohttp - # would so we don't even compute HMAC over giant junk. - if len(raw) > 3 * 1024 * 1024: - return web.Response(status=413) - # Refuse to accept anything if app_secret isn't configured. Without # it we can't authenticate the sender, and the handler would be a # data-injection point. Same defensive posture as the GET verify diff --git a/tests/gateway/test_whatsapp_cloud.py b/tests/gateway/test_whatsapp_cloud.py index c129167f032..c24e9796e0e 100644 --- a/tests/gateway/test_whatsapp_cloud.py +++ b/tests/gateway/test_whatsapp_cloud.py @@ -12,6 +12,7 @@ exercised with synthetic ``Request`` objects. from __future__ import annotations +import asyncio import json from unittest.mock import AsyncMock, MagicMock @@ -405,10 +406,22 @@ def _sign(secret: str, body: bytes) -> str: return f"sha256={digest}" +class _FakeRequestContent: + def __init__(self, body: bytes): + self.body = body + self.read_sizes: list[int] = [] + + async def readexactly(self, size: int) -> bytes: + self.read_sizes.append(size) + if len(self.body) < size: + raise asyncio.IncompleteReadError(self.body, size) + return self.body[:size] + + def _post_request(body: bytes, headers: dict | None = None): """Build a minimal aiohttp.web.Request stub for POST tests.""" request = MagicMock() - request.read = AsyncMock(return_value=body) + request.content = _FakeRequestContent(body) request.headers = headers or {} return request @@ -539,20 +552,23 @@ class TestWebhookSignature: @pytest.mark.asyncio async def test_oversize_body_rejected_before_signature(self): """3MB cap per Meta — refuse without computing HMAC over giant junk.""" + from gateway.platforms.whatsapp_cloud import WEBHOOK_MAX_BODY_BYTES + adapter = _make_adapter(app_secret="key") adapter._dispatch_payload = AsyncMock() - body = b"x" * (4 * 1024 * 1024) + body = b"x" * (WEBHOOK_MAX_BODY_BYTES + 2) request = _post_request(body, {"X-Hub-Signature-256": "sha256=ignored"}) response = await adapter._handle_webhook(request) assert response.status == 413 + assert request.content.read_sizes == [WEBHOOK_MAX_BODY_BYTES + 1] adapter._dispatch_payload.assert_not_called() @pytest.mark.asyncio async def test_unreadable_body_rejected(self): adapter = _make_adapter(app_secret="key") request = MagicMock() - request.read = AsyncMock(side_effect=RuntimeError("read failed")) + request.content.readexactly = AsyncMock(side_effect=RuntimeError("read failed")) request.headers = {} response = await adapter._handle_webhook(request) @@ -2436,4 +2452,3 @@ class TestReplyContextResolution: rich_sent_store.lookup("15551234567", "wamid.OUT") == "here is your answer" ) -