This commit is contained in:
Chukwuebuka (Gaus Octavio ) 2026-08-27 04:58:43 -04:00 committed by GitHub
commit 67c6decd34
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 955 additions and 27 deletions

View file

@ -846,8 +846,10 @@ class AnthropicHandlerMixin:
from headroom.proxy.helpers import (
MAX_MESSAGE_ARRAY_LENGTH,
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
_get_image_compressor,
compute_turn_id,
get_body_too_large_status,
read_request_json_with_bytes,
)
from headroom.proxy.modes import is_cache_mode, is_token_mode
@ -996,6 +998,18 @@ class AnthropicHandlerMixin:
try:
async with stage_timer.measure("read_request_json"):
body, original_body_bytes = await read_request_json_with_bytes(request)
except RequestBodyTooLarge as e:
await _finalize_pre_upstream()
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"type": "error",
"error": {
"type": "request_too_large",
"message": f"{e!s}",
},
},
)
except (json.JSONDecodeError, ValueError) as e:
await _finalize_pre_upstream()
return JSONResponse(
@ -4876,7 +4890,12 @@ class AnthropicHandlerMixin:
from fastapi.responses import JSONResponse, Response
from headroom.ccr import CCRToolInjector
from headroom.proxy.helpers import MAX_REQUEST_BODY_SIZE, _read_request_json
from headroom.proxy.helpers import (
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.proxy.modes import is_cache_mode
from headroom.utils import extract_user_query
@ -4900,6 +4919,17 @@ class AnthropicHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"type": "error",
"error": {
"type": "request_too_large",
"message": f"{e!s}",
},
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,

View file

@ -53,7 +53,12 @@ class BatchHandlerMixin:
from fastapi.responses import JSONResponse, Response
from headroom.ccr import CCRToolInjector
from headroom.proxy.helpers import MAX_REQUEST_BODY_SIZE, _read_request_json
from headroom.proxy.helpers import (
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.utils import extract_user_query
start_time = time.time()
@ -76,6 +81,17 @@ class BatchHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"code": get_body_too_large_status(),
"message": f"{e!s}",
"status": "INVALID_ARGUMENT",
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -804,7 +820,11 @@ class BatchHandlerMixin:
"""
from fastapi.responses import JSONResponse, Response
from headroom.proxy.helpers import _read_request_json
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
start_time = time.time()
request_id = await self._next_request_id()
@ -812,6 +832,17 @@ class BatchHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"type": "invalid_request_error",
"code": "request_too_large",
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -1218,6 +1249,7 @@ class BatchHandlerMixin:
from headroom.proxy.body_forwarding import prepare_outbound_body_bytes
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_body_bytes,
_strip_internal_headers,
log_outbound_headers,
@ -1241,8 +1273,12 @@ class BatchHandlerMixin:
# Best effort: capture the original (decompressed) bytes so the
# passthrough is truly byte-faithful. If the body was already
# consumed upstream we fall through to canonical re-serialization.
# A size-policy rejection is never best-effort: the handler's 413
# path must see it, so re-raise instead of swallowing it here.
try:
original_body_bytes: bytes | None = await _read_request_body_bytes(request)
except RequestBodyTooLarge:
raise
except Exception:
original_body_bytes = None

View file

@ -76,9 +76,11 @@ class BedrockHandlerMixin:
from headroom.proxy.helpers import (
COMPRESSION_TIMEOUT_SECONDS,
MAX_MESSAGE_ARRAY_LENGTH,
RequestBodyTooLarge,
_headroom_bypass_enabled,
_strip_internal_headers,
extract_tags,
get_body_too_large_status,
read_request_json_with_bytes,
)
from headroom.proxy.modes import is_cache_mode
@ -126,8 +128,27 @@ class BedrockHandlerMixin:
# Read the body up front so we can fail open to a verbatim forward on any
# parse error (a malformed body is the gateway's problem, not ours).
# A size-policy rejection (decompression cap) is NOT a parse error:
# it must never fail open or forward the expanding body, or the
# decompression-bomb boundary is meaningless.
try:
body, raw = await read_request_json_with_bytes(request)
except RequestBodyTooLarge as e:
logger.warning(
"[%s] %s rejecting request body past decompression cap: %s",
request_id,
LOG_TAG,
e,
)
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"type": "request_too_large",
"message": f"{e!s}",
}
},
)
except Exception as err:
from starlette.requests import ClientDisconnect

View file

@ -277,7 +277,12 @@ class GeminiHandlerMixin:
from fastapi import HTTPException
from fastapi.responses import JSONResponse, Response
from headroom.proxy.helpers import MAX_REQUEST_BODY_SIZE, _read_request_json
from headroom.proxy.helpers import (
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.utils import extract_user_query
start_time = time.time()
@ -299,6 +304,16 @@ class GeminiHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"code": get_body_too_large_status(),
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -1006,7 +1021,11 @@ class GeminiHandlerMixin:
"""Handle Pi/OpenClaw Google Cloud Code Assist and Antigravity streaming requests."""
from fastapi.responses import JSONResponse
from headroom.proxy.helpers import _read_request_json
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.utils import extract_user_query
start_time = time.time()
@ -1014,6 +1033,16 @@ class GeminiHandlerMixin:
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"code": get_body_too_large_status(),
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -1175,7 +1204,11 @@ class GeminiHandlerMixin:
"""Handle Gemini streaming endpoint /v1beta/models/{model}:streamGenerateContent."""
from fastapi.responses import JSONResponse
from headroom.proxy.helpers import _read_request_json
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
start_time = time.time()
request_id = await self._next_request_id()
@ -1183,6 +1216,16 @@ class GeminiHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"code": get_body_too_large_status(),
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -1268,7 +1311,11 @@ class GeminiHandlerMixin:
"""
from fastapi.responses import JSONResponse, Response
from headroom.proxy.helpers import _read_request_json
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.utils import extract_user_query
start_time = time.time()
@ -1277,6 +1324,16 @@ class GeminiHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"code": get_body_too_large_status(),
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,

View file

@ -3251,7 +3251,9 @@ class OpenAIHandlerMixin:
COMPRESSION_TIMEOUT_SECONDS,
MAX_MESSAGE_ARRAY_LENGTH,
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
from headroom.proxy.modes import is_cache_mode, is_token_mode
from headroom.utils import extract_user_query
@ -3284,6 +3286,17 @@ class OpenAIHandlerMixin:
# Parse request
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"type": "invalid_request_error",
"code": "request_too_large",
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -5349,6 +5362,8 @@ class OpenAIHandlerMixin:
from headroom.proxy.body_forwarding import BodyMutationTracker
from headroom.proxy.helpers import (
MAX_REQUEST_BODY_SIZE,
RequestBodyTooLarge,
get_body_too_large_status,
read_request_json_with_bytes,
)
from headroom.utils import extract_user_query
@ -5385,6 +5400,17 @@ class OpenAIHandlerMixin:
# (#1542); byte-faithful passthrough avoids that.
try:
body, original_body_bytes = await read_request_json_with_bytes(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"type": "invalid_request_error",
"code": "request_too_large",
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -9557,12 +9583,27 @@ class OpenAIHandlerMixin:
"""
from fastapi.responses import JSONResponse
from headroom.proxy.helpers import _read_request_json
from headroom.proxy.helpers import (
RequestBodyTooLarge,
_read_request_json,
get_body_too_large_status,
)
# Check bypass header
if request.headers.get("x-headroom-bypass", "").lower() == "true":
try:
body = await _read_request_json(request)
except RequestBodyTooLarge as e:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"message": f"{e!s}",
"type": "invalid_request_error",
"code": "request_too_large",
}
},
)
except (json.JSONDecodeError, ValueError) as e:
return JSONResponse(
status_code=400,
@ -9583,6 +9624,16 @@ class OpenAIHandlerMixin:
try:
body = await _read_request_json(request)
except RequestBodyTooLarge:
return JSONResponse(
status_code=get_body_too_large_status(),
content={
"error": {
"type": "invalid_request",
"message": "Request body too large.",
}
},
)
except Exception:
return JSONResponse(
status_code=400,

View file

@ -1065,6 +1065,14 @@ MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024
# Maximum SSE buffer size (10MB - prevents memory exhaustion from malformed streams)
MAX_SSE_BUFFER_SIZE = 10 * 1024 * 1024
# Hard ceiling on the *decompressed* size of request bodies. Compressed
# bodies (zstd/gzip/deflate/br) are expanded against this cap so a tiny
# compressed request cannot balloon into an unbounded in-memory buffer
# (decompression-bomb DoS). Mirrors MAX_REQUEST_BODY_SIZE: any payload that
# would exceed the uncompressed request budget is rejected outright. Read at
# call time so tests can shrink it without a module reload.
MAX_DECOMPRESSED_BODY_BYTES = MAX_REQUEST_BODY_SIZE
# Per-event SSE size cap (PR-A8 / P1-8). Configurable via
# HEADROOM_SSE_BUFFER_MAX_BYTES. Guards against pathological huge events
# (a single event > 1 MB by default is treated as an upstream protocol bug
@ -2537,12 +2545,114 @@ def apply_session_sticky_ccr_tool(
return tools_out, True
class RequestBodyTooLarge(ValueError):
"""A compressed request body expanded beyond the decompression cap.
Subclasses ``ValueError`` so the exception always degrades to a client
error even if a caller forgets to handle it explicitly. Handlers that
read request bodies MUST translate this into
:func:`get_body_too_large_status` (default 413) never a 400, and
never a fail-open forward: a size-policy rejection is a client error
distinct from malformed JSON, and the whole point of the cap is that
the expanded body must never be materialized or forwarded. The
pristine message matters: it names the limit instead of the wrapped
decompressor error.
"""
def _enforce_decompression_cap(size: int, label: str) -> None:
"""Raise :class:`RequestBodyTooLarge` if *size* exceeds the cap."""
max_output_bytes = MAX_DECOMPRESSED_BODY_BYTES
if size > max_output_bytes:
raise RequestBodyTooLarge(
f"{label} request body exceeds the {max_output_bytes}-byte decompression limit"
)
def _decompress_capped(
decompressor: Any, data: bytes, *, label: str, out: bytearray | None = None
) -> bytes:
"""Incrementally decompress *data* (zlib ``decompressobj``) under a hard cap.
``zlib.decompressobj.decompress(data, max_length)`` returns at most
``max_length`` bytes per call and parks unconsumed input on
``unconsumed_tail``, so feeding in bounded slices and checking the
running total keeps peak memory under ``MAX_DECOMPRESSED_BODY_BYTES``.
A decompression bomb becomes a clean :class:`RequestBodyTooLarge`
instead of an OOM kill. Pass *out* to accumulate across calls (used
for multi-member gzip) so the cap applies to the whole payload.
"""
out = out if out is not None else bytearray()
remaining = data
while remaining and not decompressor.eof:
chunk = decompressor.decompress(remaining, 64 * 1024)
out.extend(chunk)
_enforce_decompression_cap(len(out), label)
remaining = decompressor.unconsumed_tail
# Input exhausted without the stream end marker: truncated. The old
# one-shot calls (gzip.decompress / zlib.decompress) raised on this;
# keep that behavior so the generic handler turns it into a 400.
# (The main loop always drains pending output via unconsumed_tail, so
# there is no buffered output left to flush here.)
if not decompressor.eof:
raise ValueError(f"truncated {label} stream")
return bytes(out)
def _decompress_gzip_capped(data: bytes) -> bytes:
"""Decompress *data*, including multi-member gzip, under the hard cap.
zlib's gzip mode stops at the end of the first member and parks the
remainder on ``unused_data``, whereas ``gzip.decompress()`` used to
decompress every member. Loop the members against one shared running
total so the cap applies to the whole payload, and reject trailing
bytes that are not another gzip member (old behavior raised
``BadGzipFile`` on garbage).
"""
import zlib
out = bytearray()
remaining = data
while True:
decompressor = zlib.decompressobj(zlib.MAX_WBITS | 16)
_decompress_capped(decompressor, remaining, label="gzip", out=out)
remaining = decompressor.unused_data
if not remaining:
break
if not remaining.startswith(b"\x1f\x8b"):
raise ValueError(f"unexpected trailing data after gzip stream ({len(remaining)} bytes)")
return bytes(out)
def _decompress_zstd_capped(zstandard: Any, data: bytes) -> bytes:
"""Decompress a zstd frame via its streaming reader, capped on output.
``ZstdDecompressor.stream_reader`` can emit an unbounded output from a
small input; read it in bounded slices and enforce the same cap as the
zlib/brotli paths.
"""
reader = zstandard.ZstdDecompressor().stream_reader(data)
try:
out = bytearray()
while True:
chunk = reader.read(64 * 1024)
if not chunk:
break
out.extend(chunk)
_enforce_decompression_cap(len(out), "zstd")
return bytes(out)
finally:
reader.close()
async def _read_request_body_bytes(request: Request) -> bytes:
"""Read and (if needed) decompress the request body, returning raw UTF-8 bytes.
Mirrors ``_read_request_json`` but returns the bytes pre-parse so
forwarders can implement byte-faithful passthrough (PR-A3, fixes P0-2).
Raises ``ValueError`` on any decompression failure.
Raises ``ValueError`` on any decompression failure. Decompression is
bounded by ``MAX_DECOMPRESSED_BODY_BYTES`` so a small compressed body
cannot balloon into an unbounded in-memory buffer (decompression bomb).
"""
encoding = (request.headers.get("content-encoding") or "").lower().strip()
raw = await request.body()
@ -2550,43 +2660,56 @@ async def _read_request_body_bytes(request: Request) -> bytes:
if encoding in ("zstd", "zstandard"):
try:
import zstandard
dctx = zstandard.ZstdDecompressor()
reader = dctx.stream_reader(raw)
raw = reader.read()
reader.close()
except ImportError:
raise ValueError(
"Request body is zstd-compressed but the 'zstandard' package is not installed. "
"Install it with: pip install zstandard"
) from None
try:
raw = _decompress_zstd_capped(zstandard, raw)
except RequestBodyTooLarge:
raise
except Exception as exc:
raise ValueError(f"Failed to decompress zstd request body: {exc}") from exc
elif encoding == "gzip":
import gzip as _gzip
import zlib
try:
raw = _gzip.decompress(raw)
# zlib's gzip mode (wbits=31) gives us a bounded incremental API;
# gzip.decompress() has no output cap and would materialize a
# decompression bomb in full before we could reject it.
raw = _decompress_gzip_capped(raw)
except RequestBodyTooLarge:
raise
except Exception as exc:
raise ValueError(f"Failed to decompress gzip request body: {exc}") from exc
elif encoding == "deflate":
import zlib
try:
raw = zlib.decompress(raw)
decompressor = zlib.decompressobj()
raw = _decompress_capped(decompressor, raw, label="deflate")
except RequestBodyTooLarge:
raise
except Exception as exc:
raise ValueError(f"Failed to decompress deflate request body: {exc}") from exc
elif encoding == "br":
try:
import brotli
raw = brotli.decompress(raw)
except ImportError:
raise ValueError(
"Request body is brotli-compressed but the 'brotli' package is not installed."
) from None
except Exception as exc:
raise ValueError(f"Failed to decompress brotli request body: {exc}") from exc
# Brotli request bodies are rejected outright. The Python brotli
# bindings (brotli 1.2.0 and brotlicffi) expose no output-bounded
# streaming API: ``Decompressor.process(input_slice)`` returns ALL
# output produced from that slice before returning, so a highly
# compressible stream smaller than any feed slice can expand well
# past ``MAX_DECOMPRESSED_BODY_BYTES`` inside a single call — the
# exact allocation the cap exists to prevent. Rejecting the
# encoding (rather than decompressing) is the only way the
# process-wide decompression-bomb boundary holds for br; clients
# that send compressed request bodies can use gzip, deflate, or
# zstd, which all have bounded streaming APIs.
raise ValueError(
"Request body is brotli-compressed, but brotli request bodies are not "
"accepted: the Python brotli API cannot bound decompressed output "
"(decompression-bomb risk). Use Content-Encoding: gzip, deflate, or zstd."
)
elif encoding and encoding != "identity":
raise ValueError(f"Unsupported Content-Encoding: {encoding}")

View file

@ -0,0 +1,263 @@
"""Decompression-cap rejections must surface as body-too-large, never 400.
``RequestBodyTooLarge`` is a ``ValueError`` subclass, so a generic
``except (json.JSONDecodeError, ValueError)`` handler clause would translate
a decompression-bomb rejection into ``400 invalid_json``. Every handler
path that reads a request body must instead return the configured
body-too-large status (default 413, see ``get_body_too_large_status``), and
the Bedrock adapter must never fail open: its old ``except Exception``
path forwarded the compressed bomb verbatim to the upstream exactly the
allocation this cap exists to prevent.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from headroom.proxy.helpers import (
RequestBodyTooLarge,
get_body_too_large_status,
)
from headroom.proxy.handlers.anthropic import AnthropicHandlerMixin
from headroom.proxy.handlers.batch import BatchHandlerMixin
from headroom.proxy.handlers.bedrock import BedrockHandlerMixin
from headroom.proxy.handlers.gemini import GeminiHandlerMixin
from headroom.proxy.handlers.openai import OpenAIHandlerMixin
_TOO_LARGE = RequestBodyTooLarge(
"gzip request body exceeds the 100-byte decompression limit"
)
class _FakeState:
auth_mode = None
class _FakeRequest:
"""Minimal Starlette Request stand-in: headers, state, url, body()."""
def __init__(self, *, path: str = "/v1/messages", headers: dict | None = None) -> None:
self.headers = headers or {}
self.state = _FakeState()
self.url = SimpleNamespace(path=path, query="")
self.query_params: dict[str, str] = {}
async def body(self) -> bytes:
return b"{}"
async def _raise_too_large(request) -> None: # noqa: ANN001
raise _TOO_LARGE
def _error_payload(response) -> dict:
return json.loads(response.body)
class _FakeHttpClient:
def __init__(self) -> None:
self.posts: list[dict] = []
self.post_response = SimpleNamespace(
status_code=200,
content=b"{}",
headers={"content-type": "application/json"},
)
async def post(self, url: str, **kwargs): # noqa: ANN003, ANN201
self.posts.append({"url": url, **kwargs})
return self.post_response
class _OpenAIHandler(OpenAIHandlerMixin):
async def _next_request_id(self) -> str:
return "req-1"
class _AnthropicHandler(AnthropicHandlerMixin):
async def _next_request_id(self) -> str:
return "req-1"
class _BedrockHandler(BedrockHandlerMixin):
def __init__(self) -> None:
self.forwarded: list[dict] = []
def _bedrock_upstream_base(self) -> str:
return "https://bedrock.example"
async def _next_request_id(self) -> str:
return "req-1"
async def _forward_bedrock(self, **kwargs): # noqa: ANN003, ANN201
self.forwarded.append(kwargs)
from fastapi.responses import Response
return Response(status_code=599, content=b"should-not-be-forwarded")
class _BatchHandler(BatchHandlerMixin):
OPENAI_API_URL = "https://openai.example"
GEMINI_API_URL = "https://gemini.example"
def __init__(self) -> None:
self.http_client = _FakeHttpClient()
async def _next_request_id(self) -> str:
return "req-1"
class _GeminiHandler(GeminiHandlerMixin):
async def _next_request_id(self) -> str:
return "req-1"
# ---------------------------------------------------------------------------
# OpenAI /v1/chat/completions and /v1/responses
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_openai_chat_bomb_returns_body_too_large_status(monkeypatch) -> None:
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", _raise_too_large)
response = await _OpenAIHandler().handle_openai_chat(
_FakeRequest(path="/v1/chat/completions")
)
assert response.status_code == get_body_too_large_status()
error = _error_payload(response)["error"]
assert error["code"] == "request_too_large"
assert "decompression limit" in error["message"]
@pytest.mark.asyncio
async def test_openai_responses_bomb_returns_body_too_large_status(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.proxy.helpers.read_request_json_with_bytes", _raise_too_large
)
response = await _OpenAIHandler().handle_openai_responses(
_FakeRequest(path="/v1/responses")
)
assert response.status_code == get_body_too_large_status()
assert _error_payload(response)["error"]["code"] == "request_too_large"
@pytest.mark.asyncio
async def test_openai_chat_bomb_status_is_configurable(monkeypatch) -> None:
monkeypatch.setenv("HEADROOM_PROXY_BODY_TOO_LARGE_STATUS", "429")
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", _raise_too_large)
response = await _OpenAIHandler().handle_openai_chat(_FakeRequest())
assert response.status_code == 429
# ---------------------------------------------------------------------------
# Anthropic /v1/messages
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_anthropic_messages_bomb_returns_body_too_large_status(monkeypatch) -> None:
async def _noop(*args, **kwargs) -> None: # noqa: ANN002, ANN003
return None
monkeypatch.setattr(
"headroom.proxy.helpers.read_request_json_with_bytes", _raise_too_large
)
monkeypatch.setattr(
"headroom.proxy.handlers.anthropic.emit_stage_timings_log", _noop
)
response = await _AnthropicHandler().handle_anthropic_messages(
_FakeRequest(path="/v1/messages")
)
assert response.status_code == get_body_too_large_status()
assert _error_payload(response)["error"]["type"] == "request_too_large"
# ---------------------------------------------------------------------------
# Bedrock: must never fail open and forward the expanding body
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_bedrock_invoke_bomb_never_fails_open(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.proxy.helpers.read_request_json_with_bytes", _raise_too_large
)
handler = _BedrockHandler()
response = await handler.handle_bedrock_invoke(
_FakeRequest(path="/model/x/invoke"), "amazon.nova-pro-v1:0", stream=False
)
assert response.status_code == get_body_too_large_status()
assert handler.forwarded == [] # the compressed bomb is never forwarded
# ---------------------------------------------------------------------------
# Batch paths (OpenAI create + Google create)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_openai_batch_create_bomb_returns_body_too_large_status(monkeypatch) -> None:
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", _raise_too_large)
response = await _BatchHandler().handle_batch_create(
_FakeRequest(path="/v1/batches")
)
assert response.status_code == get_body_too_large_status()
assert _error_payload(response)["error"]["code"] == "request_too_large"
@pytest.mark.asyncio
async def test_google_batch_create_bomb_returns_body_too_large_status(monkeypatch) -> None:
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", _raise_too_large)
response = await _BatchHandler().handle_google_batch_create(
_FakeRequest(path="/v1beta/models/gemini-pro:batchGenerateContent"),
"gemini-pro",
)
assert response.status_code == get_body_too_large_status()
assert _error_payload(response)["error"]["code"] == get_body_too_large_status()
# ---------------------------------------------------------------------------
# Gemini
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_gemini_generate_content_bomb_returns_body_too_large_status(
monkeypatch,
) -> None:
monkeypatch.setattr("headroom.proxy.helpers._read_request_json", _raise_too_large)
response = await _GeminiHandler().handle_gemini_generate_content(
_FakeRequest(path="/v1beta/models/gemini-pro:generateContent"), "gemini-pro"
)
assert response.status_code == get_body_too_large_status()
assert _error_payload(response)["error"]["code"] == get_body_too_large_status()
# ---------------------------------------------------------------------------
# Batch passthrough best-effort read: size-policy rejections must propagate
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_batch_passthrough_re_raises_body_too_large(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.proxy.helpers._read_request_body_bytes", _raise_too_large
)
handler = _BatchHandler()
with pytest.raises(RequestBodyTooLarge):
await handler._batch_passthrough(_FakeRequest(), {"model": "x"})
assert handler.http_client.posts == [] # nothing forwarded
@pytest.mark.asyncio
async def test_batch_passthrough_other_read_errors_still_best_effort(monkeypatch) -> None:
async def _boom(request) -> None: # noqa: ANN001
raise RuntimeError("body already consumed upstream")
monkeypatch.setattr("headroom.proxy.helpers._read_request_body_bytes", _boom)
handler = _BatchHandler()
response = await handler._batch_passthrough(_FakeRequest(), {"model": "x"})
assert response.status_code == 200
assert handler.http_client.posts # forwarded via canonical re-serialization

View file

@ -0,0 +1,347 @@
"""Decompression of compressed request bodies must be bounded.
``_read_request_body_bytes`` (headroom/proxy/helpers.py) expands
zstd/gzip/deflate request bodies before forwarding. The expansion used
to be unbounded (``gzip.decompress`` / ``zlib.decompress`` /
``brotli.decompress`` / ``stream_reader().read()``), so a tiny compressed
body could balloon into an unbounded in-memory buffer a decompression-bomb
DoS. Every supported format is now fed incrementally against
``MAX_DECOMPRESSED_BODY_BYTES`` and raises ``RequestBodyTooLarge`` (a
``ValueError``) when the cap is exceeded.
Brotli (``br``) request bodies are rejected outright: the Python brotli
bindings expose no output-bounded streaming API, so a highly compressible
stream smaller than any feed slice can expand past the cap inside a single
``Decompressor.process()`` call the exact allocation the cap exists to
prevent. Rejecting the encoding keeps the process-wide boundary intact.
"""
import asyncio
import gzip
import json
import sys
import zlib
import pytest
from headroom.proxy.helpers import (
MAX_DECOMPRESSED_BODY_BYTES,
RequestBodyTooLarge,
_read_request_body_bytes,
)
_PAYLOAD = b"the quick brown fox jumps over the lazy dog " * 5000 # ~225 KB
class _FakeHeaders:
def __init__(self, d=None):
self._d = {k.lower(): v for k, v in (d or {}).items()}
def get(self, k, default=None):
return self._d.get(k.lower(), default)
class _FakeRequest:
def __init__(self, raw, headers=None):
self._raw = raw
self.headers = _FakeHeaders(headers)
async def body(self):
return self._raw
def _read(raw, encoding):
return asyncio.run(_read_request_body_bytes(_FakeRequest(raw, {"content-encoding": encoding})))
# ---------------------------------------------------------------------------
# Round-trips: compressed bodies still decompress byte-for-byte
# ---------------------------------------------------------------------------
def test_gzip_round_trip():
raw = gzip.compress(_PAYLOAD)
assert _read(raw, "gzip") == _PAYLOAD
def test_deflate_round_trip():
raw = zlib.compress(_PAYLOAD)
assert _read(raw, "deflate") == _PAYLOAD
def test_zstd_round_trip():
zstandard = pytest.importorskip("zstandard")
raw = zstandard.ZstdCompressor().compress(_PAYLOAD)
assert _read(raw, "zstd") == _PAYLOAD
def test_brotli_round_trip_rejected():
# br is rejected outright: the Python brotli bindings have no
# output-bounded streaming API, so even a valid small stream could
# expand past the cap inside a single Decompressor.process() call.
brotli = pytest.importorskip("brotli")
raw = brotli.compress(_PAYLOAD)
with pytest.raises(ValueError, match="brotli"):
_read(raw, "br")
# ---------------------------------------------------------------------------
# Decompression bombs: small compressed input expanding past the cap is
# rejected with RequestBodyTooLarge (a ValueError) instead of OOMing
# ---------------------------------------------------------------------------
def _bomb_under_cap(monkeypatch, compress):
# Shrink the cap so the test stays fast and memory-cheap: the bomb below
# expands 1 MB -> ~1 MB of output from ~1 KB of compressed input, which
# dwarfs the 4 KB test cap.
monkeypatch.setattr("headroom.proxy.helpers.MAX_DECOMPRESSED_BODY_BYTES", 4096)
bomb = compress(b"A" * (1024 * 1024))
assert len(bomb) < 4096 # sanity: the input itself is tiny
return bomb
def test_gzip_bomb_rejected(monkeypatch):
with pytest.raises(RequestBodyTooLarge):
_read(_bomb_under_cap(monkeypatch, gzip.compress), "gzip")
def test_deflate_bomb_rejected(monkeypatch):
with pytest.raises(RequestBodyTooLarge):
_read(_bomb_under_cap(monkeypatch, zlib.compress), "deflate")
def test_zstd_bomb_rejected(monkeypatch):
zstandard = pytest.importorskip("zstandard")
with pytest.raises(RequestBodyTooLarge):
_read(
_bomb_under_cap(monkeypatch, zstandard.ZstdCompressor().compress),
"zstd",
)
def test_brotli_peak_input_never_reaches_decompressor(monkeypatch):
"""Regression: peak-producing br input cannot cross the cap boundary.
Before this PR the brotli path fed 64 KiB input slices to
``Decompressor.process()``, which returns ALL output produced from a
slice before the cap check runs a highly compressible sub-64 KiB
stream could materialize far beyond the cap in one call. Brotli is now
rejected before any decompression happens, so the boundary holds by
construction: the bomb is refused as an unsupported encoding
(ValueError), never decompressed, and never surfaces as
RequestBodyTooLarge (which would imply a decompressor ran).
"""
brotli = pytest.importorskip("brotli")
monkeypatch.setattr("headroom.proxy.helpers.MAX_DECOMPRESSED_BODY_BYTES", 4096)
bomb = brotli.compress(b"A" * (1024 * 1024))
assert len(bomb) < 4096 # sanity: the input itself is tiny
with pytest.raises(ValueError, match="brotli"):
_read(bomb, "br")
def test_payload_at_cap_is_accepted(monkeypatch):
monkeypatch.setattr("headroom.proxy.helpers.MAX_DECOMPRESSED_BODY_BYTES", 1024)
payload = b"x" * 1024
assert _read(gzip.compress(payload), "gzip") == payload
# ---------------------------------------------------------------------------
# Error behavior preserved
# ---------------------------------------------------------------------------
def test_corrupt_gzip_still_raises_value_error():
with pytest.raises(ValueError):
_read(b"this is not a gzip stream at all", "gzip")
def test_corrupt_deflate_still_raises_value_error():
with pytest.raises(ValueError):
_read(b"this is not a deflate stream at all", "deflate")
def test_corrupt_zstd_still_raises_value_error():
zstandard = pytest.importorskip("zstandard")
del zstandard # bytes only; the ImportError path is covered separately
with pytest.raises(ValueError):
_read(b"this is not a zstd frame at all", "zstd")
def test_zstd_not_installed_raises(monkeypatch):
monkeypatch.setitem(sys.modules, "zstandard", None)
with pytest.raises(ValueError, match="not installed"):
_read(b"ignored", "zstd")
def test_brotli_rejected_regardless_of_install(monkeypatch):
# The br rejection is unconditional: the encoding is refused before any
# brotli import, so whether the package is installed or not changes
# nothing (and a missing package can no longer silently allow a bomb
# through as "not installed").
monkeypatch.setitem(sys.modules, "brotli", None)
with pytest.raises(ValueError, match="brotli"):
_read(b"ignored", "br")
def test_gzip_multi_member_round_trips():
# gzip.decompress() handled multi-member files; the incremental path
# must decompress every member against the same cap.
m1 = b"first member " * 100
m2 = b"second member " * 100
assert _read(gzip.compress(m1) + gzip.compress(m2), "gzip") == m1 + m2
def test_gzip_multi_member_cumulative_cap(monkeypatch):
# The cap applies to the whole payload, not per member.
monkeypatch.setattr("headroom.proxy.helpers.MAX_DECOMPRESSED_BODY_BYTES", 4096)
member = gzip.compress(b"A" * 2048) # 2048 B decompressed each
with pytest.raises(RequestBodyTooLarge):
_read(member + member + member, "gzip")
def test_gzip_trailing_garbage_rejected():
with pytest.raises(ValueError, match="trailing data"):
_read(gzip.compress(b"ok") + b"NOTGZIP", "gzip")
def test_truncated_gzip_still_raises_value_error():
# Header + partial body, no end marker: the old one-shot call raised
# BadGzipFile; the incremental path must raise too.
truncated = gzip.compress(_PAYLOAD)[:32]
with pytest.raises(ValueError):
_read(truncated, "gzip")
def test_gzip_payload_across_slice_boundary_round_trips():
# Exactly 64 KiB + a few bytes: exercises the multi-iteration path where
# the final output is emitted after the cap-sized slice.
payload = b"x" * (64 * 1024 + 7)
assert _read(gzip.compress(payload), "gzip") == payload
def test_truncated_brotli_still_raises_value_error():
# br is rejected before any decompression, so even a truncated stream is
# refused with the same encoding rejection (still a client-error
# ValueError, matching the pre-PR contract).
brotli = pytest.importorskip("brotli")
truncated = brotli.compress(_PAYLOAD)[:16]
with pytest.raises(ValueError, match="brotli"):
_read(truncated, "br")
def test_unsupported_encoding_raises():
with pytest.raises(ValueError):
_read(b"data", "lzma")
def test_identity_and_missing_encoding_passthrough():
payload = b'{"model": "x"}'
assert _read(payload, "identity") == payload
assert _read(payload, "") == payload
# ---------------------------------------------------------------------------
# Full path: read_request_json_with_bytes still decodes compressed JSON
# ---------------------------------------------------------------------------
def test_read_request_json_with_bytes_decompresses_gzip():
from headroom.proxy.helpers import read_request_json_with_bytes
body = {"model": "x", "messages": [{"role": "user", "content": "hi"}]}
raw = gzip.compress(json.dumps(body).encode("utf-8"))
result, out_raw = asyncio.run(
read_request_json_with_bytes(_FakeRequest(raw, {"content-encoding": "gzip"}))
)
assert result == body
assert json.loads(out_raw) == body
def test_cap_is_sane_default():
# The default cap mirrors the uncompressed request budget: a compressed
# body must never expand beyond what the handler would accept anyway.
assert MAX_DECOMPRESSED_BODY_BYTES > 0
# ---------------------------------------------------------------------------
# Integration: /v1/compress preserves body-too-large status (413 / configured)
# ---------------------------------------------------------------------------
try:
from fastapi.testclient import TestClient # noqa: F811
_HAS_FASTAPI = True
except ImportError:
_HAS_FASTAPI = False
@pytest.mark.skipif(not _HAS_FASTAPI, reason="fastapi not installed")
def test_compress_endpoint_body_too_large_gzip(monkeypatch):
"""A gzip bomb sent to /v1/compress returns the configured status, not 400."""
from headroom.proxy.helpers import get_body_too_large_status
from headroom.proxy.server import ProxyConfig, create_app
config = ProxyConfig(
optimize=True,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
)
app = create_app(config)
# Build a gzip payload whose decompressed size exceeds the cap.
# The bomb is just a highly compressible string (all 'A') so the
# compressed wire size stays tiny.
bomb_body = json.dumps({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "A" * (MAX_DECOMPRESSED_BODY_BYTES + 1)}],
}).encode("utf-8")
compressed = gzip.compress(bomb_body)
expected_status = get_body_too_large_status()
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
resp = client.post(
"/v1/compress",
content=compressed,
headers={"Content-Encoding": "gzip", "Content-Type": "application/json"},
)
assert resp.status_code == expected_status, (
f"expected {expected_status}, got {resp.status_code}: {resp.text}"
)
@pytest.mark.skipif(not _HAS_FASTAPI, reason="fastapi not installed")
def test_compress_bypass_body_too_large_gzip(monkeypatch):
"""A gzip bomb sent to /v1/compress with x-headroom-bypass also returns the configured status."""
from headroom.proxy.helpers import get_body_too_large_status
from headroom.proxy.server import ProxyConfig, create_app
config = ProxyConfig(
optimize=True,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
)
app = create_app(config)
bomb_body = json.dumps({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "A" * (MAX_DECOMPRESSED_BODY_BYTES + 1)}],
}).encode("utf-8")
compressed = gzip.compress(bomb_body)
expected_status = get_body_too_large_status()
with TestClient(app, base_url="http://127.0.0.1", client=("127.0.0.1", 12345)) as client:
resp = client.post(
"/v1/compress",
content=compressed,
headers={
"Content-Encoding": "gzip",
"Content-Type": "application/json",
"x-headroom-bypass": "true",
},
)
assert resp.status_code == expected_status, (
f"expected {expected_status}, got {resp.status_code}: {resp.text}"
)