From 15225f691d2a9fd35351e0558627d8307bffafba Mon Sep 17 00:00:00 2001 From: Tyler Coatsworth <14064505+tcoatswo@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:50:18 -0400 Subject: [PATCH] fix(proxy): reject non-object JSON request bodies --- .../proxy/common_utils/http_parsing_utils.py | 22 +++++++++++ .../common_utils/test_http_parsing_utils.py | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index aa4d6a39f25..4228460cced 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -25,6 +25,18 @@ _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form- # Binary bodies (e.g. OTLP trace exports on POST /v1/traces) are not JSON: arbitrary bytes used to # hit the JSON surrogate-repair path and fail auth with a 400. JSON under these types still parses. _BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobuf", "application/protobuf"}) +_OBJECT_ONLY_JSON_ROUTES: Final[frozenset[str]] = frozenset( + { + "/v1/chat/completions", + "/chat/completions", + "/engines/{model:path}/chat/completions", + "/openai/deployments/{model:path}/chat/completions", + "/v1/responses", + "/responses", + "/openai/v1/responses", + "/v1/messages", + } +) _ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required}) @@ -266,6 +278,16 @@ async def _read_request_body(request: Request | None) -> dict: code=status.HTTP_400_BAD_REQUEST, ) + matched_route: Final = request.scope.get("route") + route_path: Final = getattr(matched_route, "path", None) or request.scope.get("path", "") + if route_path in _OBJECT_ONLY_JSON_ROUTES and not isinstance(parsed_body, dict): + raise ProxyException( + message="Invalid JSON payload: request body must be a JSON object", + type="invalid_request_error", + param="request_body", + code=status.HTTP_400_BAD_REQUEST, + ) + # Cache the parsed result _safe_set_request_parsed_body(request=request, parsed_body=parsed_body) return parsed_body diff --git a/tests/unit/proxy/common_utils/test_http_parsing_utils.py b/tests/unit/proxy/common_utils/test_http_parsing_utils.py index fd747d5a6f2..2becc160d44 100644 --- a/tests/unit/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/unit/proxy/common_utils/test_http_parsing_utils.py @@ -2,6 +2,7 @@ import gzip import io import json from collections.abc import Mapping +from types import SimpleNamespace from typing import Final, Literal, get_type_hints from unittest.mock import AsyncMock, MagicMock, patch @@ -59,6 +60,44 @@ async def test_read_raw_json_body_returns_the_bytes_the_parsed_body_came_from(): assert await read_raw_json_body(request) == body +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("path", "route_template"), + [ + ("/v1/chat/completions", None), + ("/chat/completions", None), + ("/engines/model/chat/completions", "/engines/{model:path}/chat/completions"), + ("/openai/deployments/model/chat/completions", "/openai/deployments/{model:path}/chat/completions"), + ("/v1/responses", None), + ("/responses", None), + ("/openai/v1/responses", None), + ("/v1/messages", None), + ], +) +@pytest.mark.parametrize("body", [b"[]", b"null", b'"text"', b"1", b"true"]) +async def test_non_object_json_body_is_rejected_before_caching(body: bytes, path: str, route_template: str | None): + request: Final = _starlette_request(body, "application/json", path=path) + if route_template is not None: + request.scope["route"] = SimpleNamespace(path=route_template) + + with pytest.raises(ProxyException) as exc_info: + await _read_request_body(request) + + assert exc_info.value.code == "400" + assert exc_info.value.type == "invalid_request_error" + assert "must be a JSON object" in exc_info.value.message + assert _safe_get_request_parsed_body(request) is None + + +@pytest.mark.asyncio +async def test_passthrough_json_array_body_remains_available(): + body: Final = b'[{"role":"user","content":"hello"}]' + request: Final = _starlette_request(body, "application/json", path="/vertex-ai/v1/rawPredict") + + assert await _read_request_body(request) == json.loads(body) + assert await request.body() == body + + @pytest.mark.asyncio async def test_read_raw_json_body_is_none_until_the_body_has_been_parsed(): request = _starlette_request(b'{"model": "claude-sonnet-4-5"}', "application/json")