From 2c8340ec297f72abfe6fd404aab0717c0c658980 Mon Sep 17 00:00:00 2001 From: immortal0seeker Date: Tue, 29 Sep 2026 07:11:52 -0700 Subject: [PATCH 1/2] fix(proxy): reject non-object JSON request bodies --- litellm/proxy/common_utils/http_parsing_utils.py | 8 ++++++++ .../proxy/common_utils/test_http_parsing_utils.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 1c2bd7ea217..dbc853f8ac3 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -208,6 +208,14 @@ async def _read_request_body(request: Request | None) -> dict: code=status.HTTP_400_BAD_REQUEST, ) + if not isinstance(parsed_body, dict): + raise ProxyException( + message="JSON request body must be an 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/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index 7929a0b21af..d8c7dfd78fd 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -46,6 +46,19 @@ def _starlette_request(body: bytes, content_type: str) -> Request: return Request(scope, receive) +@pytest.mark.parametrize("body", [b"[]", b"123", b'"str"', b"true", b"null"]) +@pytest.mark.asyncio +async def test_read_request_body_rejects_non_object_json(body: bytes): + request = _starlette_request(body, "application/json") + + with pytest.raises(ProxyException) as error: + await _read_request_body(request) + + assert error.value.code == "400" + assert "JSON request body must be an object" in error.value.message + assert _safe_get_request_parsed_body(request) is None + + @pytest.mark.asyncio async def test_read_raw_json_body_returns_the_bytes_the_parsed_body_came_from(): body = b'{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}]}' From a467922889d2cc37e3602f6b77faf7e4a780bf4f Mon Sep 17 00:00:00 2001 From: immortal0seeker Date: Tue, 29 Sep 2026 08:35:45 -0700 Subject: [PATCH 2/2] fix(proxy): preserve non-object JSON for provider passthrough --- litellm/proxy/auth/user_api_key_auth.py | 11 ++++++ .../proxy/common_utils/http_parsing_utils.py | 8 ---- .../common_utils/test_http_parsing_utils.py | 37 ++++++++++++++++--- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index e3ce9bcd850..17f62b2be95 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -1373,6 +1373,17 @@ async def _read_request_body_deferring_parse_failure( parsed_body: Final = await _read_request_body(request=request) except ProxyException as parse_exception: return {}, parse_exception # mutable-ok: request_data is a plain dict across the whole auth path + if not isinstance(parsed_body, dict): + # Provider pass-through endpoints may forward a JSON array or scalar. + # Auth only needs a mapping for its own checks; leave the raw body intact. + if request_dispatched_to_pass_through_endpoint(request) or request_dispatched_to_provider_pass_through(request): + return {}, None # mutable-ok: request_data is a plain dict across the whole auth path + return {}, ProxyException( + message="JSON request body must be an object", + type="invalid_request_error", + param="request_body", + code=status.HTTP_400_BAD_REQUEST, + ) return populate_request_with_path_params(request_data=parsed_body, request=request), None diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index dbc853f8ac3..1c2bd7ea217 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -208,14 +208,6 @@ async def _read_request_body(request: Request | None) -> dict: code=status.HTTP_400_BAD_REQUEST, ) - if not isinstance(parsed_body, dict): - raise ProxyException( - message="JSON request body must be an 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/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index d8c7dfd78fd..7cbcafd0753 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -48,17 +48,42 @@ def _starlette_request(body: bytes, content_type: str) -> Request: @pytest.mark.parametrize("body", [b"[]", b"123", b'"str"', b"true", b"null"]) @pytest.mark.asyncio -async def test_read_request_body_rejects_non_object_json(body: bytes): +async def test_read_request_body_preserves_non_object_json_for_passthrough(body: bytes): request = _starlette_request(body, "application/json") - with pytest.raises(ProxyException) as error: - await _read_request_body(request) - - assert error.value.code == "400" - assert "JSON request body must be an object" in error.value.message + assert await _read_request_body(request) == orjson.loads(body) assert _safe_get_request_parsed_body(request) is None +@pytest.mark.parametrize("body", [b"[]", b"123", b'"str"', b"true", b"null"]) +@pytest.mark.asyncio +async def test_auth_defers_non_object_json_rejection(body: bytes): + from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure + + request = _starlette_request(body, "application/json") + parsed_body, error = await _read_request_body_deferring_parse_failure(request) + + assert parsed_body == {} + assert isinstance(error, ProxyException) + assert error.code == "400" + assert "JSON request body must be an object" in error.message + + +@pytest.mark.asyncio +async def test_auth_preserves_non_object_json_for_provider_passthrough(): + from litellm.proxy.auth.user_api_key_auth import _read_request_body_deferring_parse_failure + + request = _starlette_request(b'[{"prompt":"hi"}]', "application/json") + request.scope["path"] = "/vertex_ai/v1/projects/test:rawPredict" + request.scope["path_params"] = {"endpoint": "v1/projects/test:rawPredict"} + + parsed_body, error = await _read_request_body_deferring_parse_failure(request) + + assert parsed_body == {} + assert error is None + assert await request.body() == b'[{"prompt":"hi"}]' + + @pytest.mark.asyncio async def test_read_raw_json_body_returns_the_bytes_the_parsed_body_came_from(): body = b'{"model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "hi"}]}'