security(raft): enforce body-size limit on chunked requests

_handle_wake() and _handle_activity() enforced max_body_bytes only via
the Content-Length header. A Transfer-Encoding: chunked request
(content_length=None) or a spoofed small Content-Length bypassed the
cap entirely, letting the actual read be bounded only by aiohttp's
implicit 1 MiB client_max_size default (64x the 16 KB default) — the
same pattern ec29590a0 just fixed for gateway/platforms/webhook.py.

Fix: web.Application(client_max_size=self._max_body_bytes) so aiohttp
enforces the cap on every read path including chunked bodies, catch
HTTPRequestEntityTooLarge -> 413 on both endpoints (was swallowed into
a generic 400), and re-check the actual bytes read as defense in depth.
Exposure here is narrower than the webhook adapter (binds to 127.0.0.1
by default and requires the bridge token), but the bypass is otherwise
identical.
This commit is contained in:
srojk34 2026-07-05 17:23:25 +03:00 committed by Teknium
parent 1e2914b40a
commit 3817ff180d
2 changed files with 93 additions and 3 deletions

View file

@ -473,7 +473,11 @@ class RaftAdapter(BasePlatformAdapter):
self._bridge_token = secrets.token_hex(32)
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 @@ class RaftAdapter(BasePlatformAdapter):
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 @@ class RaftAdapter(BasePlatformAdapter):
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)

View file

@ -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 @@ class TestRaftActivityHttp:
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: "bridge-secret",
"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: "bridge-secret",
"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")