From 8437d2a9c3d47bc81e45a39da75b7b454b63971c Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 19:53:16 +0000 Subject: [PATCH 1/2] fix(proxy): reject request-body api_key without clientside credential opt-in A caller-supplied api_key in the JSON body overrode the deployment's admin-configured provider credential on every LLM route, with none of the opt-ins that api_base already requires. Ban it on LLM-invocation routes only, so control-plane routes that legitimately take an api_key in the body keep working. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_utils.py | 59 ++++++--- .../proxy/auth/test_auth_utils.py | 118 ++++++++++++++++++ 2 files changed, 158 insertions(+), 19 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index c9f9c00f120..ce0323f934e 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -2,7 +2,7 @@ import os import re import sys from collections.abc import Collection, Iterator, Mapping -from functools import lru_cache +from functools import lru_cache, partial from logging import Logger from typing import Any, Final, Protocol @@ -315,19 +315,36 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( *sorted(CustomPricingLiteLLMParams.model_fields.keys()), ) +# Banned only on LLM-invocation routes. ``api_key`` overrides the +# deployment's admin-configured provider credential (see the clientside +# precedence check in ``litellm_pre_call_utils``), so it needs the same +# opt-in as ``api_base``. It cannot join the list above because it is a +# legitimate body field on control-plane routes that store third-party +# credentials (e.g. POST /cloudzero/init) and on /health/test_connection. +_LLM_ROUTE_BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ("api_key",) + + +def _banned_params_for_route(route: str) -> tuple[str, ...]: + from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # auth_utils participates in a proxy import cycle + + if RouteChecks.is_llm_api_route(route): + return _BANNED_REQUEST_BODY_PARAMS + _LLM_ROUTE_BANNED_REQUEST_BODY_PARAMS + return _BANNED_REQUEST_BODY_PARAMS + def _check_banned_params( body: dict, general_settings: dict, llm_router: Router | None, model: str, + banned_params: tuple[str, ...] = _BANNED_REQUEST_BODY_PARAMS, ) -> None: """Raise ``ValueError`` if ``body`` carries a banned param without admin opt-in. Shared between the root-level check and the nested-config check so a new banned param only needs to be added in one place. """ - for param in _BANNED_REQUEST_BODY_PARAMS: + for param in banned_params: if param not in body: continue if general_settings.get("allow_client_side_credentials") is True: @@ -413,7 +430,13 @@ def _reject_url_valued_fallback_target(value: str) -> None: ) -def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: Router | None, model: str) -> bool: +def is_request_body_safe( + request_body: dict, + general_settings: dict, + llm_router: Router | None, + model: str, + route: str = "", +) -> bool: """ Check if the request body is safe. @@ -441,25 +464,27 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: """ if "model_list" in request_body: raise ValueError("Rejected Request: model_list is not allowed in the request body.") - _check_banned_params(request_body, general_settings, llm_router, model) + check_banned_params: Final = partial( + _check_banned_params, + general_settings=general_settings, + llm_router=llm_router, + model=model, + banned_params=_banned_params_for_route(route), + ) + check_banned_params(request_body) for nested_key in _NESTED_CONFIG_KEYS: nested = _coerce_metadata_to_dict(request_body.get(nested_key)) if nested is not None: - _check_banned_params(nested, general_settings, llm_router, model) + check_banned_params(nested) for metadata_key in _NESTED_METADATA_KEYS: metadata = _coerce_metadata_to_dict(request_body.get(metadata_key)) if metadata is not None: - _check_banned_params(metadata, general_settings, llm_router, model) + check_banned_params(metadata) if any(isinstance(key, str) and key.startswith(f"{metadata_key}[") for key in request_body): - _check_banned_params( - extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}["), - general_settings, - llm_router, - model, - ) + check_banned_params(extract_nested_form_metadata(form_data=request_body, prefix=f"{metadata_key}[")) for target in iter_request_fallback_targets(request_body): if isinstance(target, dict): - _check_banned_params(target, general_settings, llm_router, model) + check_banned_params(target) target_model = target.get("model") if isinstance(target_model, str): _reject_url_valued_fallback_target(target_model) @@ -469,12 +494,7 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: if litellm_params is not None: litellm_params_metadata: Final = _coerce_metadata_to_dict(litellm_params.get("metadata")) if litellm_params_metadata is not None: - _check_banned_params( - litellm_params_metadata, - general_settings, - llm_router, - model, - ) + check_banned_params(litellm_params_metadata) return True @@ -526,6 +546,7 @@ async def pre_db_read_auth_checks( general_settings=general_settings, llm_router=llm_router, model=request_data.get("model", ""), # [TODO] use model passed in url as well (azure openai routes) + route=route, ) # Check 3. Check if IP address is allowed diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 5becd05b8e8..7d8939e1006 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -3200,3 +3200,121 @@ class TestIsRequestBodySafeBlocksAwsIdentitySelectors: ) is True ) + + +class TestClientsideApiKeyRequiresAdminOptIn: + """A caller-supplied body ``api_key`` overrides the deployment's + admin-configured provider credential, so it needs the same opt-in as + ``api_base``. Before this fix the proxy silently signed the upstream + call with the caller's key on every LLM route.""" + + @pytest.mark.parametrize( + "route", + ["/v1/chat/completions", "/chat/completions", "/v1/messages", "/v1/embeddings", "/v1/responses"], + ) + def test_body_api_key_rejected_on_llm_routes(self, route): + with pytest.raises(ValueError, match="api_key"): + is_request_body_safe( + request_body={"model": "gemini-flash", "api_key": "caller-supplied-key"}, + general_settings={}, + llm_router=None, + model="gemini-flash", + route=route, + ) + + def test_extra_body_api_key_rejected(self): + with pytest.raises(ValueError, match="api_key"): + is_request_body_safe( + request_body={"model": "gemini-flash", "extra_body": {"api_key": "caller-supplied-key"}}, + general_settings={}, + llm_router=None, + model="gemini-flash", + route="/v1/chat/completions", + ) + + def test_proxy_wide_opt_in_allows_body_api_key(self): + assert ( + is_request_body_safe( + request_body={"model": "gemini-flash", "api_key": "caller-supplied-key"}, + general_settings={"allow_client_side_credentials": True}, + llm_router=None, + model="gemini-flash", + route="/v1/chat/completions", + ) + is True + ) + + def test_per_deployment_opt_in_allows_body_api_key(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "gemini-flash", + "litellm_params": { + "model": "gemini/gemini-2.5-flash", + "configurable_clientside_auth_params": ["api_key"], + }, + } + ] + ) + assert ( + is_request_body_safe( + request_body={"model": "gemini-flash", "api_key": "caller-supplied-key"}, + general_settings={}, + llm_router=router, + model="gemini-flash", + route="/v1/chat/completions", + ) + is True + ) + + def test_body_without_api_key_still_accepted(self): + assert ( + is_request_body_safe( + request_body={"model": "gemini-flash", "messages": [{"role": "user", "content": "hi"}]}, + general_settings={}, + llm_router=None, + model="gemini-flash", + route="/v1/chat/completions", + ) + is True + ) + + def test_control_plane_route_still_accepts_body_api_key(self): + # /cloudzero/init and friends store a third-party credential sent in + # the body; they must not be caught by the LLM-route-only ban. + assert ( + is_request_body_safe( + request_body={"api_key": "cz-key", "connection_id": "conn-1"}, + general_settings={}, + llm_router=None, + model="", + route="/cloudzero/init", + ) + is True + ) + + +@pytest.mark.asyncio +async def test_pre_db_read_auth_checks_rejects_body_api_key_on_llm_route(): + """The route has to be plumbed through to ``is_request_body_safe``, otherwise + the ban never applies to a real request.""" + from litellm.proxy.auth.auth_utils import pre_db_read_auth_checks + + request = Request( + scope={ + "type": "http", + "method": "POST", + "path": "/v1/chat/completions", + "headers": [], + "client": ("1.2.3.4", 1234), + } + ) + with patch("litellm.proxy.proxy_server.general_settings", {}): + with pytest.raises(ValueError, match="api_key"): + await pre_db_read_auth_checks( + request=request, + request_data={"model": "gemini-flash", "api_key": "caller-supplied-key"}, + route="/v1/chat/completions", + ) From 056629e6abf321bc179cfb24dcf9628e701668e9 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 13 Aug 2026 20:02:14 +0000 Subject: [PATCH 2/2] style: shorten noqa reason to satisfy ruff import sorting Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index ce0323f934e..4cc2f1a691b 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -325,7 +325,7 @@ _LLM_ROUTE_BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ("api_key",) def _banned_params_for_route(route: str) -> tuple[str, ...]: - from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # auth_utils participates in a proxy import cycle + from litellm.proxy.auth.route_checks import RouteChecks # noqa: PLC0415 # proxy import cycle if RouteChecks.is_llm_api_route(route): return _BANNED_REQUEST_BODY_PARAMS + _LLM_ROUTE_BANNED_REQUEST_BODY_PARAMS