fix(webhook): enforce body-size limit on chunked requests

The webhook adapter 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 and read the full
body (bounded only by aiohttp's implicit 1 MiB default, above any
operator-configured smaller limit).

- web.Application(client_max_size=max_body_bytes): aiohttp enforces the
  cap on every read path, chunked included
- catch HTTPRequestEntityTooLarge -> 413 (was swallowed into generic 400)
- post-read length re-check as defense in depth
- chunked-upload regression test

Manual port of PR #3955 by @Gutslabs onto current main (handler had
been restructured since); authorship preserved.
This commit is contained in:
Gutslabs 2026-07-04 15:33:31 -07:00 committed by teknium1
parent 6fcd470d54
commit ec29590a0f
No known key found for this signature in database
2 changed files with 41 additions and 2 deletions

View file

@ -191,7 +191,10 @@ class WebhookAdapter(BasePlatformAdapter):
f"real target (telegram, discord, slack, github_comment, etc.)."
)
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 check below.
app = web.Application(client_max_size=self._max_body_bytes)
app.router.add_get("/health", self._handle_health)
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
# Multi-profile multiplexing: a /p/<profile>/webhooks/<route> prefix
@ -479,9 +482,21 @@ class WebhookAdapter(BasePlatformAdapter):
# Read body (must be done before any validation)
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(
{"error": "Payload too large"}, status=413
)
except Exception as e:
logger.error("[webhook] Failed to read body: %s", e)
return web.json_response({"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(
{"error": "Payload too large"}, status=413
)
# Validate HMAC signature FIRST (skip only for the explicit local-test
# INSECURE_NO_AUTH mode). Missing/empty secrets must fail closed here,

View file

@ -69,7 +69,8 @@ def _make_adapter(routes=None, **kwargs):
def _create_app(adapter: WebhookAdapter) -> web.Application:
"""Build the aiohttp Application from the adapter (without starting a full server)."""
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("/webhooks/{route_name}", adapter._handle_webhook)
return app
@ -741,6 +742,29 @@ class TestBodySize:
)
assert resp.status == 413
@pytest.mark.asyncio
async def test_chunked_oversized_payload_rejected(self):
"""Chunked request bodies (no Content-Length) over the limit return 413."""
routes = {"big": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
adapter = _make_adapter(routes=routes, max_body_bytes=100)
adapter.handle_message = AsyncMock()
async def _chunked_body():
payload = json.dumps({"data": "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 cli:
resp = await cli.post(
"/webhooks/big",
data=_chunked_body(),
headers={"Content-Type": "application/json"},
)
assert resp.status == 413
adapter.handle_message.assert_not_awaited()
# ===================================================================
# INSECURE_NO_AUTH