From ec29590a0f193590e24012ff6c165de5ba2930d6 Mon Sep 17 00:00:00 2001 From: Gutslabs Date: Sat, 4 Jul 2026 15:33:31 -0700 Subject: [PATCH 1/2] 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. --- gateway/platforms/webhook.py | 17 ++++++++++++++++- tests/gateway/test_webhook_adapter.py | 26 +++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/webhook.py b/gateway/platforms/webhook.py index 9fb72b6ec81..c9acf61b32c 100644 --- a/gateway/platforms/webhook.py +++ b/gateway/platforms/webhook.py @@ -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//webhooks/ 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, diff --git a/tests/gateway/test_webhook_adapter.py b/tests/gateway/test_webhook_adapter.py index 9e5340042ee..3c5c59a2001 100644 --- a/tests/gateway/test_webhook_adapter.py +++ b/tests/gateway/test_webhook_adapter.py @@ -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 From 2b4ec0082a4734758a4cda43cf81d1b9b93f9e7e Mon Sep 17 00:00:00 2001 From: Gutslabs Date: Sat, 4 Jul 2026 15:35:05 -0700 Subject: [PATCH 2/2] fix(api_server): return 413 for oversized chunked bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api_server already caps every read via client_max_size (chunked included), but when the limit tripped mid-read the handler's broad JSON except turned it into 400 'Invalid JSON'. Catch HTTPRequestEntityTooLarge in body_limit_middleware and return the OpenAI-style 413. Status-code polish extracted from PR #3949 by @Gutslabs — the PR's core client_max_size change already exists on main. --- gateway/platforms/api_server.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index a029596849d..5ba09d67492 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -669,7 +669,16 @@ if AIOHTTP_AVAILABLE: return web.json_response(_openai_error("Request body too large.", code="body_too_large"), status=413) except ValueError: return web.json_response(_openai_error("Invalid Content-Length header.", code="invalid_content_length"), status=400) - return await handler(request) + try: + return await handler(request) + except web.HTTPRequestEntityTooLarge: + # aiohttp's client_max_size tripped mid-read (chunked bodies carry + # no Content-Length) — return a proper 413 instead of letting the + # handler's broad JSON except turn it into 400 "Invalid JSON". + return web.json_response( + _openai_error("Request body too large.", code="body_too_large"), + status=413, + ) else: body_limit_middleware = None # type: ignore[assignment]