Enforce body-size limits on chunked webhook requests
Cluster 54844 · agent. Data as of
Nothing open on GitHub. Fix merged in #58902 on 5 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #58902. 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")
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#54845 Enforce Raft bridge body limit while reading · superseded by another PR →
#54844,#58902,#58536,#54931,#54934,#54938,#54944
Closed issues · 1
- Closed#54844Enforce Raft bridge body limit while reading
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: 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