Enforce body-size limits on chunked webhook requests
NousResearch/hermes-agent · cluster 12543 · agent
Data as of
Nothing open on GitHub. Fix merged in #58536. Check it covers your case.
The short answer
- Known?
- Yes. 13 related items grouped here, 5 issues already closed.
- Fix event
- A fix was merged to main in PR #58536. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs address a gap where chunked HTTP requests without Content-Length headers bypass max_body_bytes limits across multiple webhook integrations (WhatsApp Cloud, Feishu, LINE, Raft bridge). The work focuses on enforcing body-size caps during reading rather than after buffering.
Open issues0
Open PRs0
Fixes on record
-
#58536 fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)
files:
gateway/platforms/api_server.py,gateway/platforms/webhook.py,tests/gateway/test_webhook_adapter.py -
#58902 security(raft): enforce body-size limit on chunked requests
files:
plugins/platforms/raft/adapter.py,tests/gateway/test_raft_adapter.pyShow the diff
-
plugins/platforms/raft/adapter.py@@ -473,7 +473,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._bridge_token = [redacted:secret-assignment] logger.info("[raft] Auto-generated bridge token") - app = web.Application() + # client_max_size makes aiohttp enforce the cap on every read path, + # including Transfer-Encoding: chunked bodies that carry no + # Content-Length and would otherwise bypass the header checks below + # (mirrors gateway/platforms/webhook.py's connect()). + app = web.Application(client_max_size=self._max_body_bytes) app.router.add_get("/health", self._handle_health) app.router.add_post(self._path, self._handle_wake) app.router.add_post("/activity", self._handle_activity) @@ -600,8 +604,16 @@ async def _handle_wake(self, request: "web.Request") -> "web.Response": try: raw_body = await request.read() + except web.HTTPRequestEntityTooLarge: + # aiohttp's client_max_size tripped — chunked or lying + # Content-Length. Same 413 as the header check above. + return web.json_response({"ok": False, "error": "payload_too_large"}, status=413) except Exception: return web.json_response({"ok": False, "error": "bad_request"}, status=400) + if len(raw_body) > self._max_body_bytes: + # Defense in depth: enforce the cap on the actual bytes read even + # if the server-level limit was bypassed or misconfigured. + return web.json_response({"ok": False, "error": "payload_too_large"}, status=413) payload: Dict[str, Any] = {} if raw_body.strip(): @@ -646,7 +658,20 @@ async def _handle_activity(self, request: "web.Request") -> "web.Response": return web.json_response({"ok": False, "error": "payload_too_large"}, status=413) try: - payload = json.loads(await request.text()) + raw_text = await request.text() + except web.HTTPRequestEntityTooLarge: + # aiohttp's client_max_size tripped — chunked or lying + # Content-Length. Same 413 as the header check above. + return web.json_response({"ok": False, "error": "payload_too_large"}, status=413) + except Exception as exc: + return web.json_response({"ok": False, "error": str(exc)}, status=400) + if len(raw_text.encode("utf-8")) > self._max_body_bytes: + # Defense in depth: enforce the cap on the actual bytes read even + # if the server-level limit was bypassed or misconfigured. + return web.json_response({"ok": False, "error": "payload_too_large"}, status=413) + + try: + payload = json.loads(raw_text) self._activity_queue.push(payload) except json.JSONDecodeError: return web.json_response({"ok": False, "error": "invalid_json"}, status=400) -
tests/gateway/test_raft_adapter.py@@ -1,5 +1,7 @@ """Tests for the Raft channel adapter.""" +import asyncio +import json import os from unittest.mock import AsyncMock, patch @@ -56,7 +58,8 @@ def _make_adapter(**extra): def _create_app(adapter: RaftAdapter) -> web.Application: - app = web.Application() + # Mirror connect(): client_max_size enforces the cap on chunked bodies. + app = web.Application(client_max_size=adapter._max_body_bytes) app.router.add_get("/health", adapter._handle_health) app.router.add_post(adapter._path, adapter._handle_wake) app.router.add_post("/activity", adapter._handle_activity) @@ -407,6 +410,68 @@ def test_interrupted_turn_reports_error_stop(self): assert drain["events"][1]["errorClass"] == "interrupted" +class TestBodySize: + """The wake/activity endpoints enforced max_body_bytes only via the + Content-Length header; a Transfer-Encoding: chunked request + (content_length=None) bypassed the cap entirely and read the full body, + bounded only by aiohttp's implicit 1 MiB default. Mirrors + gateway/platforms/webhook.py's TestBodySize.""" + + @pytest.mark.asyncio + async def test_wake_chunked_oversized_payload_rejected(self): + adapter = _make_adapter(max_body_bytes=100) + adapter.set_message_handler(AsyncMock()) + adapter.handle_message = AsyncMock() + + async def _chunked_body(): + payload = json.dumps({"eventId": "x" * 500}).encode("utf-8") + for i in range(0, len(payload), 64): + yield payload[i : i + 64] + await asyncio.sleep(0) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + DEFAULT_PATH, + data=_chunked_body(), + headers={ + BRIDGE_TOKEN_HEADER: "[redacted:secret-assignment]", + "Content-Type": "application/json", + }, + ) + assert resp.status == 413 + body = await resp.json() + + assert body == {"ok": False, "error": "payload_too_large"} + adapter.handle_message.assert_not_awaited() + + @pytest.mark.asyncio + async def test_activity_chunked_oversized_payload_rejected(self): + adapter = _make_adapter(max_body_bytes=100) + + async def _chunked_body(): + payload = json.dumps(_activity_event("x" * 500)).encode("utf-8") + for i in range(0, len(payload), 64): + yield payload[i : i + 64] + await asyncio.sleep(0) + + app = _create_app(adapter) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/activity", + data=_chunked_body(), + headers={ + BRIDGE_TOKEN_HEADER: "[redacted:secret-assignment]", + "Content-Type": "application/json", + }, + ) + assert resp.status == 413 + body = await resp.json() + + assert body == {"ok": False, "error": "payload_too_large"} + assert adapter._activity_queue.drain()["events"] == [] + + class TestRaftConfig: def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch): monkeypatch.setenv("RAFT_PROFILE", "my-agent")
-
-
#59215 security(gateway): complete the webhook body-cap sweep (salvages #54620, #25296, #54944, #54938)
files:
gateway/platforms/msgraph_webhook.py,gateway/platforms/whatsapp_cloud.py,plugins/platforms/feishu/adapter.py,plugins/platforms/sms/adapter.py,scripts/release.py,tests/gateway/test_feishu.py,tests/gateway/test_msgraph_webhook.py,tests/gateway/test_sms.py,tests/gateway/test_whatsapp_cloud.pyShow the diff
-
gateway/platforms/msgraph_webhook.py@@ -34,6 +34,7 @@ DEFAULT_PORT = 8646 DEFAULT_WEBHOOK_PATH = "/msgraph/webhook" DEFAULT_MAX_SEEN_RECEIPTS = 5000 +DEFAULT_MAX_BODY_BYTES = 1_048_576 NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None] @@ -63,6 +64,9 @@ def __init__(self, config: PlatformConfig): self._max_seen_receipts = max( 1, int(extra.get("max_seen_receipts", DEFAULT_MAX_SEEN_RECEIPTS)) ) + self._max_body_bytes = max( + 1, int(extra.get("max_body_bytes", DEFAULT_MAX_BODY_BYTES)) + ) self._allowed_source_networks: list[ipaddress._BaseNetwork] = ( self._parse_allowed_source_cidrs(extra.get("allowed_source_cidrs")) ) @@ -152,7 +156,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: ) return False - app = web.Application() + app = web.Application(client_max_size=self._max_body_bytes) app.router.add_get(self._health_path, self._handle_health) app.router.add_get(self._webhook_path, self._handle_validation) app.router.add_post(self._webhook_path, self._handle_notification) @@ -229,9 +233,25 @@ async def _handle_notification(self, request: "web.Request") -> "web.Response": return web.Response(text=validation_token, content_type="text/plain") try: - body = await request.json() + content_length = request.content_length + except Exception: + content_length = None + if content_length is not None and content_length > self._max_body_bytes: + return web.Response(status=413) + + try: + raw_body = await request.read() except Exception: return web.Response(status=400) + if len(raw_body) > self._max_body_bytes: + return web.Response(status=413) + + try: + body = json.loads(raw_body.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError): + return web.Response(status=400) + if not isinstance(body, dict): + return web.Response(status=400) notifications = body.get("value") if not isinstance(notifications, list): -
gateway/platforms/whatsapp_cloud.py@@ -90,6 +90,7 @@ 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 @@ } +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. @@ -403,7 +415,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: ) # Inbound webhook server. - app = web.Application() + # client_max_size backstops the bounded reader in _handle_webhook — + # aiohttp enforces the cap on request.read()/post() paths too + # (#58536/#58902/#59180 pattern). + app = web.Application(client_max_size=WEBHOOK_MAX_BODY_BYTES) app.router.add_get(self._health_path, self._handle_health) app.router.add_get(self._webhook_path, self._handle_verify) app.router.add_post(self._webhook_path, self._handle_webhook) @@ -1405,16 +1420,18 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": 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 -
plugins/platforms/feishu/adapter.py@@ -228,6 +228,19 @@ "always": "Approved permanently", "deny": "Denied", } + + +async def _read_limited_feishu_webhook_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 + + _FEISHU_BOT_MSG_TRACK_SIZE = 512 # LRU size for tracking sent message IDs _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003}) # reply target withdrawn/missing → create fallback @@ -3467,9 +3480,16 @@ async def _handle_webhook_request(self, request: Any) -> Any: try: body_bytes: bytes = await asyncio.wait_for( - request.read(), + _read_limited_feishu_webhook_body( + request, + _FEISHU_WEBHOOK_MAX_BODY_BYTES, + ), timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, ) + except ValueError: + logger.warning("[Feishu] Webhook body exceeds limit from %s", remote_ip) + self._record_webhook_anomaly(remote_ip, "413") + return web.Response(status=413, text="Request body too large") except asyncio.TimeoutError: logger.warning("[Feishu] Webhook body read timed out after %ds from %s", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip) self._record_webhook_anomaly(remote_ip, "408") @@ -3478,11 +3498,6 @@ async def _handle_webhook_request(self, request: Any) -> Any: self._record_webhook_anomaly(remote_ip, "400") return web.json_response({"code": 400, "msg": "failed to read body"}, status=400) - if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES: - logger.warning("[Feishu] Webhook body exceeds limit (%d bytes) from %s", len(body_bytes), remote_ip) - self._record_webhook_anomaly(remote_ip, "413") - return web.Response(status=413, text="Request body too large") - try: payload = json.loads(body_bytes.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): @@ -4732,7 +4747,10 @@ async def _connect_webhook(self) -> None: if self._event_handler is None: raise RuntimeError("failed to build Feishu event handler") await self._hydrate_bot_identity() - app = web.Application() + # client_max_size backstops the bounded reader in + # _handle_webhook_request; aiohttp then enforces the same cap on + # every read path (#58536/#58902/#59180 pattern). + app = web.Application(client_max_size=_FEISHU_WEBHOOK_MAX_BODY_BYTES) app.router.add_post(self._webhook_path, self._handle_webhook_request) self._webhook_runner = web.AppRunner(app) await self._webhook_runner.setup() -
plugins/platforms/sms/adapter.py@@ -42,6 +42,7 @@ MAX_SMS_LENGTH = 1600 # ~10 SMS segments DEFAULT_WEBHOOK_PORT = 8080 DEFAULT_WEBHOOK_HOST = "127.0.0.1" +_TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536 # 64 KiB — Twilio payloads are small def check_sms_requirements() -> bool: @@ -118,7 +119,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._webhook_port, ) - app = web.Application() + # client_max_size bounds every read path — including chunked bodies + # with no Content-Length — before the handler's own 413 checks run + # (#58536/#58902/#59180 pattern). + app = web.Application(client_max_size=_TWILIO_WEBHOOK_MAX_BODY_BYTES) app.router.add_post("/webhooks/twilio", self._handle_webhook) app.router.add_get("/health", lambda _: web.Response(text="ok")) @@ -293,7 +297,20 @@ async def _handle_webhook(self, request) -> "aiohttp.web.Response": from aiohttp import web try: + content_length = request.content_length + if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES: + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + status=413, + ) raw = await request.read() + if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES: + return web.Response( + text='<?xml version="1.0" encoding="UTF-8"?><Response></Response>', + content_type="application/xml", + status=413, + ) # Twilio sends form-encoded data, not JSON form = urllib.parse.parse_qs(raw.decode("utf-8"), keep_blank_values=True) except Exception as e: -
scripts/release.py@@ -730,6 +730,7 @@ "[redacted:email]": "Hypnus-Yuan", "[redacted:email]": "xsfX20", "[redacted:email]": "binhnt92", + "[redacted:email]": "Alix-007", # PR #54620 salvage (sms: bound Twilio webhook body reads) "[redacted:email]": "acesjohnny", "[redacted:email]": "liu-collab", "[redacted:email]": "haidao1919", -
tests/gateway/test_feishu.py@@ -21,6 +21,18 @@ _HAS_LARK_OAPI = False +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 _mock_event_dispatcher_builder(mock_handler_class): mock_builder = Mock() mock_builder.register_p2_im_message_message_read_v1 = Mock(return_value=mock_builder) @@ -178,7 +190,7 @@ def test_connect_webhook_mode_starts_local_server(self): runner = AsyncMock() site = AsyncMock() web_module = SimpleNamespace( - Application=lambda: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)), + Application=lambda **_kwargs: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)), AppRunner=lambda _app: runner, TCPSite=lambda _runner, host, port: SimpleNamespace(start=site.start, host=host, port=port), ) @@ -1604,7 +1616,7 @@ def test_webhook_request_uses_same_message_dispatch_path(self): remote="127.0.0.1", content_length=None, headers={}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) @@ -1633,7 +1645,7 @@ def test_url_verification_requires_configured_verification_token(self): remote="203.0.113.10", content_length=None, headers={}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) @@ -3261,6 +3273,30 @@ def test_webhook_request_rejects_oversized_body(self): response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 413) + def test_webhook_request_rejects_oversized_chunked_body_while_reading(self): + from gateway.config import PlatformConfig + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + from plugins.platforms.feishu.adapter import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES + + with tempfile.TemporaryDirectory() as tmpdir: + token = [redacted:secret-assignment] + try: + adapter = FeishuAdapter(PlatformConfig()) + finally: + reset_hermes_home_override(token) + content = _FakeRequestContent(b"A" * (_FEISHU_WEBHOOK_MAX_BODY_BYTES + 2)) + request = SimpleNamespace( + remote="127.0.0.1", + content_length=None, + headers={}, + content=content, + ) + + response = asyncio.run(adapter._handle_webhook_request(request)) + + self.assertEqual(response.status, 413) + self.assertEqual(content.read_sizes, [_FEISHU_WEBHOOK_MAX_BODY_BYTES + 1]) + @patch.dict(os.environ, {}, clear=True) def test_webhook_request_rejects_invalid_json(self): from gateway.config import PlatformConfig @@ -3270,7 +3306,7 @@ def test_webhook_request_rejects_invalid_json(self): request = SimpleNamespace( remote="127.0.0.1", content_length=None, - read=AsyncMock(return_value=b"not-json"), + content=_FakeRequestContent(b"not-json"), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 400) @@ -3286,7 +3322,7 @@ def test_webhook_request_rejects_bad_signature(self): remote="127.0.0.1", content_length=None, headers={"x-lark-request-timestamp": "123", "x-lark-request-nonce": "abc", "x-lark-signature": "bad"}, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 401) @@ -3335,7 +3371,7 @@ def test_webhook_url_verification_challenge_passes_without_signature(self): request = SimpleNamespace( remote="127.0.0.1", content_length=None, - read=AsyncMock(return_value=body), + content=_FakeRequestContent(body), ) response = asyncio.run(adapter._handle_webhook_request(request)) self.assertEqual(response.status, 200) -
tests/gateway/test_msgraph_webhook.py@@ -1,6 +1,7 @@ """Tests for the Microsoft Graph webhook adapter.""" import asyncio +import json import pytest @@ -19,9 +20,19 @@ def _make_adapter(**extra_overrides) -> MSGraphWebhookAdapter: class _FakeRequest: - def __init__(self, *, query=None, json_payload=None, remote="127.0.0.1"): + def __init__( + self, + *, + query=None, + json_payload=None, + raw_body: bytes | None = None, + content_length: int | None = None, + remote="127.0.0.1", + ): self.query = query or {} self._json_payload = json_payload + self._raw_body = raw_body + self.content_length = content_length self.remote = remote async def json(self): @@ -29,6 +40,11 @@ async def json(self): raise self._json_payload return self._json_payload + async def read(self): + if self._raw_body is not None: + return self._raw_body + return json.dumps(self._json_payload or {}).encode("utf-8") + class TestMSGraphWebhookConfig: def test_gateway_config_accepts_msgraph_webhook_platform(self): @@ -183,6 +199,62 @@ async def _capture(notification, event): assert event.source.chat_type == "webhook" assert event.message_id == "id:notif-1" + @pytest.mark.anyio + async def test_oversized_notification_rejected_by_content_length(self): + adapter = _make_adapter(max_body_bytes=100) + payload = { + "value": [ + { + "id": "notif-oversized", + "subscriptionId": "sub-1", + "changeType": "updated", + "resource": "communications/onlineMeetings/meeting-1", + "clientState": "expected-client-state", + } + ] + } + + resp = await adapter._handle_notification( + _FakeRequest(json_payload=payload, content_length=101) + ) + + assert resp.status == 413 + + @pytest.mark.anyio + async def test_chunked_oversized_notification_rejected_after_read(self): + adapter = _make_adapter(max_body_bytes=100) + payload = { + "value": [ + { + "id": "notif-chunked-oversized", + "subscriptionId": "sub-1", + "changeType": "updated", + "resource": "communications/onlineMeetings/meeting-1", + "clientState": "expected-client-state", + } + ] + } + + resp = await adapter._handle_notification( + _FakeRequest( + json_payload=payload, + raw_body=b"x" * 101, + content_length=None, + ) + ) + + assert resp.status == 413 + + @pytest.mark.anyio + async def test_non_object_notification_body_rejected(self): + adapter = _make_adapter() + + resp = await adapter._handle_notification( + _FakeRequest(json_payload=[], raw_body=b"[]") + ) + + assert resp.status == 400 + @pytest.mark.anyio async def test_bad_client_state_rejected_as_auth_failure(self): """Every-item-bad-clientState batches return 403 so forged POSTs stop retrying.""" -
tests/gateway/test_sms.py@@ -459,10 +459,11 @@ def _make_adapter(self, webhook_url=""): adapter._message_handler = AsyncMock() return adapter - def _mock_request(self, body, headers=None): + def _mock_request(self, body, headers=None, content_length=None): request = MagicMock() request.read = AsyncMock(return_value=body) request.headers = headers or {} + request.content_length = content_length return request @pytest.mark.asyncio @@ -536,3 +537,27 @@ async def test_port_variant_signature_returns_200(self): request = self._mock_request(body, headers={"X-Twilio-Signature": sig}) resp = await adapter._handle_webhook(request) assert resp.status == 200 + + @pytest.mark.asyncio + async def test_webhook_rejects_oversized_body_via_content_length(self): + """POST with Content-Length exceeding 64 KiB returns 413 before reading.""" + adapter = self._make_adapter(webhook_url="") + body = b"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123" + request = self._mock_request(body, content_length=65_537) + resp = await adapter._handle_webhook(request) + assert resp.status == 413 + # request.read must NOT have been called — we bailed on Content-Length + request.read.assert_not_called() + + @pytest.mark.asyncio + async def test_webhook_rejects_oversized_body_via_read_length(self): + """POST whose actual read size exceeds 64 KiB returns 413. + + Covers the case where Content-Length is absent (chunked transfer) but + the body still exceeds the cap. + """ + adapter = self._make_adapter(webhook_url="") + oversized = b"x" * 65_537 + request = self._mock_request(oversized, content_length=None) + resp = await adapter._handle_webhook(request) + assert resp.status == 413 -
tests/gateway/test_whatsapp_cloud.py@@ -12,6 +12,7 @@ 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 @@ async def test_signature_uses_constant_time_compare(self): @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 @@ async def test_send_records_outbound_text_by_wamid(self): rich_sent_store.lookup("15551234567", "wamid.OUT") == "here is your answer" ) -
-
5 PRs closed without merging
From the close comments: done elsewhere 2 · merged via another change 2 · superseded by another PR 1
-
#13336 fix(webhook): cap chunked request bodies · done elsewhere →
ec29590,f23026f,#58536,#3955 -
#54845 Enforce Raft bridge body limit while reading · superseded by another PR →
#54844,#58902,#58536,#54931,#54934,#54938,#54944 -
#54931 Enforce LINE webhook body limit while reading · done elsewhere →
#58902 -
#54938 Enforce Feishu webhook body limit while reading · merged via another change →
#59215 -
#54944 Enforce WhatsApp Cloud webhook body limit while reading · merged via another change →
#59215
Closed issues · 5
- Closed#12543webhook: max_body_bytes check is bypassed for chunked requests without Content-Length
- Closed#54844Enforce Raft bridge body limit while reading
- Closed#54930Enforce LINE webhook body limit while reading
- Closed#54935Feishu webhook buffers oversized chunked bodies before 413
- Closed#54940WhatsApp Cloud webhook buffers oversized bodies before 413
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.17: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab