NousResearch/hermes-agent

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

0open issues
0open PRs
4items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #58536 fix(gateway): enforce body-size limits on chunked requests (salvage #3955 + #3949)

    merged to main

    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

    files: plugins/platforms/raft/adapter.py, tests/gateway/test_raft_adapter.py

    Show 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.00 means 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

At a glance

Subsystem
agent
Merged PRs
#58536, #58902
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.