{"repo":"NousResearch/hermes-agent","public_id":"54844","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":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58902,"merged_at":"2026-07-05T20:57:38+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T20:57:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":12543,"pr_number":58536,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T07:44:37+00:00"},{"issue_number":54844,"pr_number":58536,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T07:44:37+00:00"},{"issue_number":54844,"pr_number":58902,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T20:57:38+00:00"},{"issue_number":54930,"pr_number":58902,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T20:57:38+00:00"}],"members":[{"kind":"issue","number":54844,"title":"Enforce Raft bridge body limit while reading","url":null,"state":"closed"},{"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":"#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":58536,"title":"fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)","url":null,"state":"merged","close_reason":{"category":"merged"},"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"},"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"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}