Enforce WhatsApp Cloud webhook body limit while reading

This commit is contained in:
luyifan 2026-06-29 22:36:06 +08:00 committed by Teknium
parent deae37e33b
commit eec92a92c0
2 changed files with 39 additions and 10 deletions

View file

@ -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

View file

@ -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"
)