{"repo":"NousResearch/hermes-agent","public_id":"12543","display_label":"Enforce body-size limits on chunked webhook requests","summary":"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.","subsystem":"agent","counts":{"members":13,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":3,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58536},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-06T00:38:36+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.16871794871794868},"fix_edges":[{"issue_number":12543,"pr_number":58536,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54844,"pr_number":58536,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54844,"pr_number":58902,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54930,"pr_number":58902,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54935,"pr_number":59215,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54940,"pr_number":59215,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":12543,"title":"webhook: max_body_bytes check is bypassed for chunked requests without Content-Length","url":null,"state":"closed"},{"kind":"issue","number":54844,"title":"Enforce Raft bridge body limit while reading","url":null,"state":"closed"},{"kind":"issue","number":54930,"title":"Enforce LINE webhook body limit while reading","url":null,"state":"closed"},{"kind":"issue","number":54935,"title":"Feishu webhook buffers oversized chunked bodies before 413","url":null,"state":"closed"},{"kind":"issue","number":54940,"title":"WhatsApp Cloud webhook buffers oversized bodies before 413","url":null,"state":"closed"},{"kind":"pr","number":13336,"title":"fix(webhook): cap chunked request bodies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ec29590a0f193590e24012ff6c165de5ba2930d6","number":0},{"kind":"commit","value":"f23026f9792640fbc7eb0f8656f256bc85d3bc19","number":0},{"kind":"pr","value":"#58536","number":58536},{"kind":"pr","value":"#3955","number":3955}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54845,"title":"Enforce Raft bridge body limit while reading","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54844","number":54844},{"kind":"pr","value":"#58902","number":58902},{"kind":"pr","value":"#58536","number":58536},{"kind":"pr","value":"#58902","number":58902},{"kind":"pr","value":"#54931","number":54931},{"kind":"pr","value":"#54934","number":54934},{"kind":"pr","value":"#54938","number":54938},{"kind":"pr","value":"#54944","number":54944}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":54931,"title":"Enforce LINE webhook body limit while reading","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58902","number":58902}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":54938,"title":"Enforce Feishu webhook body limit while reading","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59215","number":59215}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54944,"title":"Enforce WhatsApp Cloud webhook body limit while reading","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#59215","number":59215}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58536,"title":"fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","gateway/platforms/webhook.py","tests/gateway/test_webhook_adapter.py"]},{"kind":"pr","number":58902,"title":"security(raft): enforce body-size limit on chunked requests","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/raft/adapter.py","tests/gateway/test_raft_adapter.py"],"fix_hunks":[{"file":"plugins/platforms/raft/adapter.py","hunks":["@@ -473,7 +473,11 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             self._bridge_token = [redacted:secret-assignment]\n             logger.info(\"[raft] Auto-generated bridge token\")\n \n-        app = web.Application()\n+        # client_max_size makes aiohttp enforce the cap on every read path,\n+        # including Transfer-Encoding: chunked bodies that carry no\n+        # Content-Length and would otherwise bypass the header checks below\n+        # (mirrors gateway/platforms/webhook.py's connect()).\n+        app = web.Application(client_max_size=self._max_body_bytes)\n         app.router.add_get(\"/health\", self._handle_health)\n         app.router.add_post(self._path, self._handle_wake)\n         app.router.add_post(\"/activity\", self._handle_activity)","@@ -600,8 +604,16 @@ async def _handle_wake(self, request: \"web.Request\") -> \"web.Response\":\n \n         try:\n             raw_body = await request.read()\n+        except web.HTTPRequestEntityTooLarge:\n+            # aiohttp's client_max_size tripped — chunked or lying\n+            # Content-Length. Same 413 as the header check above.\n+            return web.json_response({\"ok\": False, \"error\": \"payload_too_large\"}, status=413)\n         except Exception:\n             return web.json_response({\"ok\": False, \"error\": \"bad_request\"}, status=400)\n+        if len(raw_body) > self._max_body_bytes:\n+            # Defense in depth: enforce the cap on the actual bytes read even\n+            # if the server-level limit was bypassed or misconfigured.\n+            return web.json_response({\"ok\": False, \"error\": \"payload_too_large\"}, status=413)\n \n         payload: Dict[str, Any] = {}\n         if raw_body.strip():","@@ -646,7 +658,20 @@ async def _handle_activity(self, request: \"web.Request\") -> \"web.Response\":\n             return web.json_response({\"ok\": False, \"error\": \"payload_too_large\"}, status=413)\n \n         try:\n-            payload = json.loads(await request.text())\n+            raw_text = await request.text()\n+        except web.HTTPRequestEntityTooLarge:\n+            # aiohttp's client_max_size tripped — chunked or lying\n+            # Content-Length. Same 413 as the header check above.\n+            return web.json_response({\"ok\": False, \"error\": \"payload_too_large\"}, status=413)\n+        except Exception as exc:\n+            return web.json_response({\"ok\": False, \"error\": str(exc)}, status=400)\n+        if len(raw_text.encode(\"utf-8\")) > self._max_body_bytes:\n+            # Defense in depth: enforce the cap on the actual bytes read even\n+            # if the server-level limit was bypassed or misconfigured.\n+            return web.json_response({\"ok\": False, \"error\": \"payload_too_large\"}, status=413)\n+\n+        try:\n+            payload = json.loads(raw_text)\n             self._activity_queue.push(payload)\n         except json.JSONDecodeError:\n             return web.json_response({\"ok\": False, \"error\": \"invalid_json\"}, status=400)"]},{"file":"tests/gateway/test_raft_adapter.py","hunks":["@@ -1,5 +1,7 @@\n \"\"\"Tests for the Raft channel adapter.\"\"\"\n \n+import asyncio\n+import json\n import os\n from unittest.mock import AsyncMock, patch\n ","@@ -56,7 +58,8 @@ def _make_adapter(**extra):\n \n \n def _create_app(adapter: RaftAdapter) -> web.Application:\n-    app = web.Application()\n+    # Mirror connect(): client_max_size enforces the cap on chunked bodies.\n+    app = web.Application(client_max_size=adapter._max_body_bytes)\n     app.router.add_get(\"/health\", adapter._handle_health)\n     app.router.add_post(adapter._path, adapter._handle_wake)\n     app.router.add_post(\"/activity\", adapter._handle_activity)","@@ -407,6 +410,68 @@ def test_interrupted_turn_reports_error_stop(self):\n         assert drain[\"events\"][1][\"errorClass\"] == \"interrupted\"\n \n \n+class TestBodySize:\n+    \"\"\"The wake/activity endpoints enforced max_body_bytes only via the\n+    Content-Length header; a Transfer-Encoding: chunked request\n+    (content_length=None) bypassed the cap entirely and read the full body,\n+    bounded only by aiohttp's implicit 1 MiB default. Mirrors\n+    gateway/platforms/webhook.py's TestBodySize.\"\"\"\n+\n+    @pytest.mark.asyncio\n+    async def test_wake_chunked_oversized_payload_rejected(self):\n+        adapter = _make_adapter(max_body_bytes=100)\n+        adapter.set_message_handler(AsyncMock())\n+        adapter.handle_message = AsyncMock()\n+\n+        async def _chunked_body():\n+            payload = json.dumps({\"eventId\": \"x\" * 500}).encode(\"utf-8\")\n+            for i in range(0, len(payload), 64):\n+                yield payload[i : i + 64]\n+                await asyncio.sleep(0)\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as client:\n+            resp = await client.post(\n+                DEFAULT_PATH,\n+                data=_chunked_body(),\n+                headers={\n+                    BRIDGE_TOKEN_HEADER: \"[redacted:secret-assignment]\",\n+                    \"Content-Type\": \"application/json\",\n+                },\n+            )\n+            assert resp.status == 413\n+            body = await resp.json()\n+\n+        assert body == {\"ok\": False, \"error\": \"payload_too_large\"}\n+        adapter.handle_message.assert_not_awaited()\n+\n+    @pytest.mark.asyncio\n+    async def test_activity_chunked_oversized_payload_rejected(self):\n+        adapter = _make_adapter(max_body_bytes=100)\n+\n+        async def _chunked_body():\n+            payload = json.dumps(_activity_event(\"x\" * 500)).encode(\"utf-8\")\n+            for i in range(0, len(payload), 64):\n+                yield payload[i : i + 64]\n+                await asyncio.sleep(0)\n+\n+        app = _create_app(adapter)\n+        async with TestClient(TestServer(app)) as client:\n+            resp = await client.post(\n+                \"/activity\",\n+                data=_chunked_body(),\n+                headers={\n+                    BRIDGE_TOKEN_HEADER: \"[redacted:secret-assignment]\",\n+                    \"Content-Type\": \"application/json\",\n+                },\n+            )\n+            assert resp.status == 413\n+            body = await resp.json()\n+\n+        assert body == {\"ok\": False, \"error\": \"payload_too_large\"}\n+        assert adapter._activity_queue.drain()[\"events\"] == []\n+\n+\n class TestRaftConfig:\n     def test_env_enablement_auto_enables_with_raft_profile(self, monkeypatch):\n         monkeypatch.setenv(\"RAFT_PROFILE\", \"my-agent\")\n"]}]},{"kind":"pr","number":59215,"title":"security(gateway): complete the webhook body-cap sweep (salvages #54620, #25296, #54944, #54938)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"gateway/platforms/msgraph_webhook.py","hunks":["@@ -34,6 +34,7 @@\n DEFAULT_PORT = 8646\n DEFAULT_WEBHOOK_PATH = \"/msgraph/webhook\"\n DEFAULT_MAX_SEEN_RECEIPTS = 5000\n+DEFAULT_MAX_BODY_BYTES = 1_048_576\n NotificationScheduler = Callable[[Dict[str, Any], MessageEvent], Awaitable[None] | None]\n \n ","@@ -63,6 +64,9 @@ def __init__(self, config: PlatformConfig):\n         self._max_seen_receipts = max(\n             1, int(extra.get(\"max_seen_receipts\", DEFAULT_MAX_SEEN_RECEIPTS))\n         )\n+        self._max_body_bytes = max(\n+            1, int(extra.get(\"max_body_bytes\", DEFAULT_MAX_BODY_BYTES))\n+        )\n         self._allowed_source_networks: list[ipaddress._BaseNetwork] = (\n             self._parse_allowed_source_cidrs(extra.get(\"allowed_source_cidrs\"))\n         )","@@ -152,7 +156,7 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n             )\n             return False\n \n-        app = web.Application()\n+        app = web.Application(client_max_size=self._max_body_bytes)\n         app.router.add_get(self._health_path, self._handle_health)\n         app.router.add_get(self._webhook_path, self._handle_validation)\n         app.router.add_post(self._webhook_path, self._handle_notification)","@@ -229,9 +233,25 @@ async def _handle_notification(self, request: \"web.Request\") -> \"web.Response\":\n             return web.Response(text=validation_token, content_type=\"text/plain\")\n \n         try:\n-            body = await request.json()\n+            content_length = request.content_length\n+        except Exception:\n+            content_length = None\n+        if content_length is not None and content_length > self._max_body_bytes:\n+            return web.Response(status=413)\n+\n+        try:\n+            raw_body = await request.read()\n         except Exception:\n             return web.Response(status=400)\n+        if len(raw_body) > self._max_body_bytes:\n+            return web.Response(status=413)\n+\n+        try:\n+            body = json.loads(raw_body.decode(\"utf-8\"))\n+        except (json.JSONDecodeError, UnicodeDecodeError):\n+            return web.Response(status=400)\n+        if not isinstance(body, dict):\n+            return web.Response(status=400)\n \n         notifications = body.get(\"value\")\n         if not isinstance(notifications, list):"]},{"file":"gateway/platforms/whatsapp_cloud.py","hunks":["@@ -90,6 +90,7 @@\n DEFAULT_WEBHOOK_PORT = 8090\n DEFAULT_WEBHOOK_PATH = \"/whatsapp/webhook\"\n GRAPH_API_BASE = \"https://graph.facebook.com\"\n+WEBHOOK_MAX_BODY_BYTES = 3 * 1024 * 1024\n # Meta retries failed webhooks for up to 7 days. We don't need to remember\n # every wamid for the full retry window — the practical risk is duplicate\n # delivery within minutes, not days. 5000 entries with FIFO eviction is","@@ -144,6 +145,17 @@\n }\n \n \n+async def _read_limited_request_body(request: Any, max_bytes: int) -> bytes:\n+    \"\"\"Read at most ``max_bytes`` from an aiohttp request body.\"\"\"\n+    try:\n+        body = await request.content.readexactly(max_bytes + 1)\n+    except asyncio.IncompleteReadError as exc:\n+        body = exc.partial\n+    if len(body) > max_bytes:\n+        raise ValueError(\"payload too large\")\n+    return body\n+\n+\n def _ext_for_mime(mime: str) -> Optional[str]:\n     \"\"\"Resolve a mime type to the file extension we want on disk.\n ","@@ -403,7 +415,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n         )\n \n         # Inbound webhook server.\n-        app = web.Application()\n+        # client_max_size backstops the bounded reader in _handle_webhook —\n+        # aiohttp enforces the cap on request.read()/post() paths too\n+        # (#58536/#58902/#59180 pattern).\n+        app = web.Application(client_max_size=WEBHOOK_MAX_BODY_BYTES)\n         app.router.add_get(self._health_path, self._handle_health)\n         app.router.add_get(self._webhook_path, self._handle_verify)\n         app.router.add_post(self._webhook_path, self._handle_webhook)","@@ -1405,16 +1420,18 @@ async def _handle_webhook(self, request: \"web.Request\") -> \"web.Response\":\n              multiply downstream agent work because of a transient bug\n              during dispatch.\n         \"\"\"\n+        # Meta's documented max payload is 3MB. Read one byte past the limit\n+        # so oversized chunked bodies are rejected before buffering the rest.\n         try:\n-            raw = await request.read()\n+            raw = await _read_limited_request_body(\n+                request,\n+                WEBHOOK_MAX_BODY_BYTES,\n+            )\n+        except ValueError:\n+            return web.Response(status=413)\n         except Exception:\n             return web.Response(status=400)\n \n-        # Meta's documented max payload is 3MB. Reject earlier than aiohttp\n-        # would so we don't even compute HMAC over giant junk.\n-        if len(raw) > 3 * 1024 * 1024:\n-            return web.Response(status=413)\n-\n         # Refuse to accept anything if app_secret isn't configured. Without\n         # it we can't authenticate the sender, and the handler would be a\n         # data-injection point. Same defensive posture as the GET verify"]},{"file":"plugins/platforms/feishu/adapter.py","hunks":["@@ -228,6 +228,19 @@\n     \"always\": \"Approved permanently\",\n     \"deny\": \"Denied\",\n }\n+\n+\n+async def _read_limited_feishu_webhook_body(request: Any, max_bytes: int) -> bytes:\n+    \"\"\"Read at most ``max_bytes`` from an aiohttp request body.\"\"\"\n+    try:\n+        body = await request.content.readexactly(max_bytes + 1)\n+    except asyncio.IncompleteReadError as exc:\n+        body = exc.partial\n+    if len(body) > max_bytes:\n+        raise ValueError(\"payload too large\")\n+    return body\n+\n+\n _FEISHU_BOT_MSG_TRACK_SIZE = 512                   # LRU size for tracking sent message IDs\n _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003})  # reply target withdrawn/missing → create fallback\n ","@@ -3467,9 +3480,16 @@ async def _handle_webhook_request(self, request: Any) -> Any:\n \n         try:\n             body_bytes: bytes = await asyncio.wait_for(\n-                request.read(),\n+                _read_limited_feishu_webhook_body(\n+                    request,\n+                    _FEISHU_WEBHOOK_MAX_BODY_BYTES,\n+                ),\n                 timeout=_FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS,\n             )\n+        except ValueError:\n+            logger.warning(\"[Feishu] Webhook body exceeds limit from %s\", remote_ip)\n+            self._record_webhook_anomaly(remote_ip, \"413\")\n+            return web.Response(status=413, text=\"Request body too large\")\n         except asyncio.TimeoutError:\n             logger.warning(\"[Feishu] Webhook body read timed out after %ds from %s\", _FEISHU_WEBHOOK_BODY_TIMEOUT_SECONDS, remote_ip)\n             self._record_webhook_anomaly(remote_ip, \"408\")","@@ -3478,11 +3498,6 @@ async def _handle_webhook_request(self, request: Any) -> Any:\n             self._record_webhook_anomaly(remote_ip, \"400\")\n             return web.json_response({\"code\": 400, \"msg\": \"failed to read body\"}, status=400)\n \n-        if len(body_bytes) > _FEISHU_WEBHOOK_MAX_BODY_BYTES:\n-            logger.warning(\"[Feishu] Webhook body exceeds limit (%d bytes) from %s\", len(body_bytes), remote_ip)\n-            self._record_webhook_anomaly(remote_ip, \"413\")\n-            return web.Response(status=413, text=\"Request body too large\")\n-\n         try:\n             payload = json.loads(body_bytes.decode(\"utf-8\"))\n         except (json.JSONDecodeError, UnicodeDecodeError):","@@ -4732,7 +4747,10 @@ async def _connect_webhook(self) -> None:\n         if self._event_handler is None:\n             raise RuntimeError(\"failed to build Feishu event handler\")\n         await self._hydrate_bot_identity()\n-        app = web.Application()\n+        # client_max_size backstops the bounded reader in\n+        # _handle_webhook_request; aiohttp then enforces the same cap on\n+        # every read path (#58536/#58902/#59180 pattern).\n+        app = web.Application(client_max_size=_FEISHU_WEBHOOK_MAX_BODY_BYTES)\n         app.router.add_post(self._webhook_path, self._handle_webhook_request)\n         self._webhook_runner = web.AppRunner(app)\n         await self._webhook_runner.setup()"]},{"file":"plugins/platforms/sms/adapter.py","hunks":["@@ -42,6 +42,7 @@\n MAX_SMS_LENGTH = 1600  # ~10 SMS segments\n DEFAULT_WEBHOOK_PORT = 8080\n DEFAULT_WEBHOOK_HOST = \"127.0.0.1\"\n+_TWILIO_WEBHOOK_MAX_BODY_BYTES = 65_536  # 64 KiB — Twilio payloads are small\n \n \n def check_sms_requirements() -> bool:","@@ -118,7 +119,10 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:\n                 self._webhook_port,\n             )\n \n-        app = web.Application()\n+        # client_max_size bounds every read path — including chunked bodies\n+        # with no Content-Length — before the handler's own 413 checks run\n+        # (#58536/#58902/#59180 pattern).\n+        app = web.Application(client_max_size=_TWILIO_WEBHOOK_MAX_BODY_BYTES)\n         app.router.add_post(\"/webhooks/twilio\", self._handle_webhook)\n         app.router.add_get(\"/health\", lambda _: web.Response(text=\"ok\"))\n ","@@ -293,7 +297,20 @@ async def _handle_webhook(self, request) -> \"aiohttp.web.Response\":\n         from aiohttp import web\n \n         try:\n+            content_length = request.content_length\n+            if content_length is not None and content_length > _TWILIO_WEBHOOK_MAX_BODY_BYTES:\n+                return web.Response(\n+                    text='<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>',\n+                    content_type=\"application/xml\",\n+                    status=413,\n+                )\n             raw = await request.read()\n+            if len(raw) > _TWILIO_WEBHOOK_MAX_BODY_BYTES:\n+                return web.Response(\n+                    text='<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response></Response>',\n+                    content_type=\"application/xml\",\n+                    status=413,\n+                )\n             # Twilio sends form-encoded data, not JSON\n             form = urllib.parse.parse_qs(raw.decode(\"utf-8\"), keep_blank_values=True)\n         except Exception as e:"]},{"file":"scripts/release.py","hunks":["@@ -730,6 +730,7 @@\n     \"[redacted:email]\": \"Hypnus-Yuan\",\n     \"[redacted:email]\": \"xsfX20\",\n     \"[redacted:email]\": \"binhnt92\",\n+    \"[redacted:email]\": \"Alix-007\",  # PR #54620 salvage (sms: bound Twilio webhook body reads)\n     \"[redacted:email]\": \"acesjohnny\",\n     \"[redacted:email]\": \"liu-collab\",\n     \"[redacted:email]\": \"haidao1919\","]},{"file":"tests/gateway/test_feishu.py","hunks":["@@ -21,6 +21,18 @@\n     _HAS_LARK_OAPI = False\n \n \n+class _FakeRequestContent:\n+    def __init__(self, body: bytes):\n+        self.body = body\n+        self.read_sizes: list[int] = []\n+\n+    async def readexactly(self, size: int) -> bytes:\n+        self.read_sizes.append(size)\n+        if len(self.body) < size:\n+            raise asyncio.IncompleteReadError(self.body, size)\n+        return self.body[:size]\n+\n+\n def _mock_event_dispatcher_builder(mock_handler_class):\n     mock_builder = Mock()\n     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):\n         runner = AsyncMock()\n         site = AsyncMock()\n         web_module = SimpleNamespace(\n-            Application=lambda: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)),\n+            Application=lambda **_kwargs: SimpleNamespace(router=SimpleNamespace(add_post=lambda *_args, **_kwargs: None)),\n             AppRunner=lambda _app: runner,\n             TCPSite=lambda _runner, host, port: SimpleNamespace(start=site.start, host=host, port=port),\n         )","@@ -1604,7 +1616,7 @@ def test_webhook_request_uses_same_message_dispatch_path(self):\n             remote=\"127.0.0.1\",\n             content_length=None,\n             headers={},\n-            read=AsyncMock(return_value=body),\n+            content=_FakeRequestContent(body),\n         )\n \n         response = asyncio.run(adapter._handle_webhook_request(request))","@@ -1633,7 +1645,7 @@ def test_url_verification_requires_configured_verification_token(self):\n             remote=\"203.0.113.10\",\n             content_length=None,\n             headers={},\n-            read=AsyncMock(return_value=body),\n+            content=_FakeRequestContent(body),\n         )\n \n         response = asyncio.run(adapter._handle_webhook_request(request))","@@ -3261,6 +3273,30 @@ def test_webhook_request_rejects_oversized_body(self):\n         response = asyncio.run(adapter._handle_webhook_request(request))\n         self.assertEqual(response.status, 413)\n \n+    def test_webhook_request_rejects_oversized_chunked_body_while_reading(self):\n+        from gateway.config import PlatformConfig\n+        from hermes_constants import reset_hermes_home_override, set_hermes_home_override\n+        from plugins.platforms.feishu.adapter import FeishuAdapter, _FEISHU_WEBHOOK_MAX_BODY_BYTES\n+\n+        with tempfile.TemporaryDirectory() as tmpdir:\n+            token = [redacted:secret-assignment]\n+            try:\n+                adapter = FeishuAdapter(PlatformConfig())\n+            finally:\n+                reset_hermes_home_override(token)\n+            content = _FakeRequestContent(b\"A\" * (_FEISHU_WEBHOOK_MAX_BODY_BYTES + 2))\n+            request = SimpleNamespace(\n+                remote=\"127.0.0.1\",\n+                content_length=None,\n+                headers={},\n+                content=content,\n+            )\n+\n+            response = asyncio.run(adapter._handle_webhook_request(request))\n+\n+            self.assertEqual(response.status, 413)\n+            self.assertEqual(content.read_sizes, [_FEISHU_WEBHOOK_MAX_BODY_BYTES + 1])\n+\n     @patch.dict(os.environ, {}, clear=True)\n     def test_webhook_request_rejects_invalid_json(self):\n         from gateway.config import PlatformConfig","@@ -3270,7 +3306,7 @@ def test_webhook_request_rejects_invalid_json(self):\n         request = SimpleNamespace(\n             remote=\"127.0.0.1\",\n             content_length=None,\n-            read=AsyncMock(return_value=b\"not-json\"),\n+            content=_FakeRequestContent(b\"not-json\"),\n         )\n         response = asyncio.run(adapter._handle_webhook_request(request))\n         self.assertEqual(response.status, 400)","@@ -3286,7 +3322,7 @@ def test_webhook_request_rejects_bad_signature(self):\n             remote=\"127.0.0.1\",\n             content_length=None,\n             headers={\"x-lark-request-timestamp\": \"123\", \"x-lark-request-nonce\": \"abc\", \"x-lark-signature\": \"bad\"},\n-            read=AsyncMock(return_value=body),\n+            content=_FakeRequestContent(body),\n         )\n         response = asyncio.run(adapter._handle_webhook_request(request))\n         self.assertEqual(response.status, 401)","@@ -3335,7 +3371,7 @@ def test_webhook_url_verification_challenge_passes_without_signature(self):\n         request = SimpleNamespace(\n             remote=\"127.0.0.1\",\n             content_length=None,\n-            read=AsyncMock(return_value=body),\n+            content=_FakeRequestContent(body),\n         )\n         response = asyncio.run(adapter._handle_webhook_request(request))\n         self.assertEqual(response.status, 200)"]},{"file":"tests/gateway/test_msgraph_webhook.py","hunks":["@@ -1,6 +1,7 @@\n \"\"\"Tests for the Microsoft Graph webhook adapter.\"\"\"\n \n import asyncio\n+import json\n \n import pytest\n ","@@ -19,9 +20,19 @@ def _make_adapter(**extra_overrides) -> MSGraphWebhookAdapter:\n \n \n class _FakeRequest:\n-    def __init__(self, *, query=None, json_payload=None, remote=\"127.0.0.1\"):\n+    def __init__(\n+        self,\n+        *,\n+        query=None,\n+        json_payload=None,\n+        raw_body: bytes | None = None,\n+        content_length: int | None = None,\n+        remote=\"127.0.0.1\",\n+    ):\n         self.query = query or {}\n         self._json_payload = json_payload\n+        self._raw_body = raw_body\n+        self.content_length = content_length\n         self.remote = remote\n \n     async def json(self):","@@ -29,6 +40,11 @@ async def json(self):\n             raise self._json_payload\n         return self._json_payload\n \n+    async def read(self):\n+        if self._raw_body is not None:\n+            return self._raw_body\n+        return json.dumps(self._json_payload or {}).encode(\"utf-8\")\n+\n \n class TestMSGraphWebhookConfig:\n     def test_gateway_config_accepts_msgraph_webhook_platform(self):","@@ -183,6 +199,62 @@ async def _capture(notification, event):\n         assert event.source.chat_type == \"webhook\"\n         assert event.message_id == \"id:notif-1\"\n \n+    @pytest.mark.anyio\n+    async def test_oversized_notification_rejected_by_content_length(self):\n+        adapter = _make_adapter(max_body_bytes=100)\n+        payload = {\n+            \"value\": [\n+                {\n+                    \"id\": \"notif-oversized\",\n+                    \"subscriptionId\": \"sub-1\",\n+                    \"changeType\": \"updated\",\n+                    \"resource\": \"communications/onlineMeetings/meeting-1\",\n+                    \"clientState\": \"expected-client-state\",\n+                }\n+            ]\n+        }\n+\n+        resp = await adapter._handle_notification(\n+            _FakeRequest(json_payload=payload, content_length=101)\n+        )\n+\n+        assert resp.status == 413\n+\n+    @pytest.mark.anyio\n+    async def test_chunked_oversized_notification_rejected_after_read(self):\n+        adapter = _make_adapter(max_body_bytes=100)\n+        payload = {\n+            \"value\": [\n+                {\n+                    \"id\": \"notif-chunked-oversized\",\n+                    \"subscriptionId\": \"sub-1\",\n+                    \"changeType\": \"updated\",\n+                    \"resource\": \"communications/onlineMeetings/meeting-1\",\n+                    \"clientState\": \"expected-client-state\",\n+                }\n+            ]\n+        }\n+\n+        resp = await adapter._handle_notification(\n+            _FakeRequest(\n+                json_payload=payload,\n+                raw_body=b\"x\" * 101,\n+                content_length=None,\n+            )\n+        )\n+\n+        assert resp.status == 413\n+\n+    @pytest.mark.anyio\n+    async def test_non_object_notification_body_rejected(self):\n+        adapter = _make_adapter()\n+\n+        resp = await adapter._handle_notification(\n+            _FakeRequest(json_payload=[], raw_body=b\"[]\")\n+        )\n+\n+        assert resp.status == 400\n+\n     @pytest.mark.anyio\n     async def test_bad_client_state_rejected_as_auth_failure(self):\n         \"\"\"Every-item-bad-clientState batches return 403 so forged POSTs stop retrying.\"\"\""]},{"file":"tests/gateway/test_sms.py","hunks":["@@ -459,10 +459,11 @@ def _make_adapter(self, webhook_url=\"\"):\n         adapter._message_handler = AsyncMock()\n         return adapter\n \n-    def _mock_request(self, body, headers=None):\n+    def _mock_request(self, body, headers=None, content_length=None):\n         request = MagicMock()\n         request.read = AsyncMock(return_value=body)\n         request.headers = headers or {}\n+        request.content_length = content_length\n         return request\n \n     @pytest.mark.asyncio","@@ -536,3 +537,27 @@ async def test_port_variant_signature_returns_200(self):\n         request = self._mock_request(body, headers={\"X-Twilio-Signature\": sig})\n         resp = await adapter._handle_webhook(request)\n         assert resp.status == 200\n+\n+    @pytest.mark.asyncio\n+    async def test_webhook_rejects_oversized_body_via_content_length(self):\n+        \"\"\"POST with Content-Length exceeding 64 KiB returns 413 before reading.\"\"\"\n+        adapter = self._make_adapter(webhook_url=\"\")\n+        body = b\"From=%2B15551234567&To=%2B15550001111&Body=hello&MessageSid=SM123\"\n+        request = self._mock_request(body, content_length=65_537)\n+        resp = await adapter._handle_webhook(request)\n+        assert resp.status == 413\n+        # request.read must NOT have been called — we bailed on Content-Length\n+        request.read.assert_not_called()\n+\n+    @pytest.mark.asyncio\n+    async def test_webhook_rejects_oversized_body_via_read_length(self):\n+        \"\"\"POST whose actual read size exceeds 64 KiB returns 413.\n+\n+        Covers the case where Content-Length is absent (chunked transfer) but\n+        the body still exceeds the cap.\n+        \"\"\"\n+        adapter = self._make_adapter(webhook_url=\"\")\n+        oversized = b\"x\" * 65_537\n+        request = self._mock_request(oversized, content_length=None)\n+        resp = await adapter._handle_webhook(request)\n+        assert resp.status == 413"]},{"file":"tests/gateway/test_whatsapp_cloud.py","hunks":["@@ -12,6 +12,7 @@\n \n from __future__ import annotations\n \n+import asyncio\n import json\n from unittest.mock import AsyncMock, MagicMock\n ","@@ -405,10 +406,22 @@ def _sign(secret: str, body: bytes) -> str:\n     return f\"sha256={digest}\"\n \n \n+class _FakeRequestContent:\n+    def __init__(self, body: bytes):\n+        self.body = body\n+        self.read_sizes: list[int] = []\n+\n+    async def readexactly(self, size: int) -> bytes:\n+        self.read_sizes.append(size)\n+        if len(self.body) < size:\n+            raise asyncio.IncompleteReadError(self.body, size)\n+        return self.body[:size]\n+\n+\n def _post_request(body: bytes, headers: dict | None = None):\n     \"\"\"Build a minimal aiohttp.web.Request stub for POST tests.\"\"\"\n     request = MagicMock()\n-    request.read = AsyncMock(return_value=body)\n+    request.content = _FakeRequestContent(body)\n     request.headers = headers or {}\n     return request\n ","@@ -539,20 +552,23 @@ async def test_signature_uses_constant_time_compare(self):\n     @pytest.mark.asyncio\n     async def test_oversize_body_rejected_before_signature(self):\n         \"\"\"3MB cap per Meta — refuse without computing HMAC over giant junk.\"\"\"\n+        from gateway.platforms.whatsapp_cloud import WEBHOOK_MAX_BODY_BYTES\n+\n         adapter = _make_adapter(app_secret=\"key\")\n         adapter._dispatch_payload = AsyncMock()\n-        body = b\"x\" * (4 * 1024 * 1024)\n+        body = b\"x\" * (WEBHOOK_MAX_BODY_BYTES + 2)\n         request = _post_request(body, {\"X-Hub-Signature-256\": \"sha256=ignored\"})\n \n         response = await adapter._handle_webhook(request)\n         assert response.status == 413\n+        assert request.content.read_sizes == [WEBHOOK_MAX_BODY_BYTES + 1]\n         adapter._dispatch_payload.assert_not_called()\n \n     @pytest.mark.asyncio\n     async def test_unreadable_body_rejected(self):\n         adapter = _make_adapter(app_secret=\"key\")\n         request = MagicMock()\n-        request.read = AsyncMock(side_effect=RuntimeError(\"read failed\"))\n+        request.content.readexactly = AsyncMock(side_effect=RuntimeError(\"read failed\"))\n         request.headers = {}\n \n         response = await adapter._handle_webhook(request)","@@ -2436,4 +2452,3 @@ async def test_send_records_outbound_text_by_wamid(self):\n             rich_sent_store.lookup(\"15551234567\", \"wamid.OUT\")\n             == \"here is your answer\"\n         )\n-\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}