# NousResearch/hermes-agent · Enforce body-size limits on chunked webhook requests

## Open now

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

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #58902. Verify it addresses your case.
- **Recommended:** read PR #58902 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #54844: Enforce Raft bridge body limit while reading

## Prior & competing PRs

- **#58536: fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/raft/adapter.py`, `tests/gateway/test_raft_adapter.py`
  - fix hunks:
  - `plugins/platforms/raft/adapter.py`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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():
    ```

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

    ```diff
    @@ -1,5 +1,7 @@
     """Tests for the Raft channel adapter."""
     
    +import asyncio
    +import json
     import os
     from unittest.mock import AsyncMock, patch
     
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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")
    ```
- **#54845: Enforce Raft bridge body limit while reading**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54844`, `#58902`, `#58536`, `#54931`, `#54934`, `#54938`, `#54944`

## Fix status

- Merged to main in PR #58902 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `agent`
