From c9645e2ddaedfbc48534f43e08c105a76e87ea90 Mon Sep 17 00:00:00 2001 From: hx <1367557521@qq.com> Date: Sun, 27 Sep 2026 18:07:43 +0800 Subject: [PATCH] fix(xai): reject xai_oauth_token_file in request bodies regardless of client-side opt-ins The token file picks which OAuth account stored on the proxy host signs the request, so allow_client_side_credentials and configurable_clientside_auth_params must not hand it to callers. Only deployment config may set it Co-authored-by: Cursor --- .../exception_mapping_utils.py | 23 -------- litellm/proxy/auth/auth_utils.py | 16 +++-- .../proxy/auth/test_auth_utils.py | 59 +++++++++++-------- 3 files changed, 45 insertions(+), 53 deletions(-) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 6e4e8af5eb3..0fdfb301291 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -437,29 +437,6 @@ def _map_openai_exception( response=response, litellm_debug_info=extra_information, ) - elif original_exception.status_code == 403: - from litellm.llms.xai.oauth import is_xai_spending_limit_error - - if is_xai_spending_limit_error( - custom_llm_provider=custom_llm_provider, - status_code=original_exception.status_code, - error_str=error_str, - ): - raise RateLimitError( - message=f"RateLimitError: {exception_provider} - {message}", - model=model, - llm_provider=custom_llm_provider, - response=getattr(original_exception, "response", None), - litellm_debug_info=extra_information, - ) - raise APIError( - status_code=original_exception.status_code, - message=f"APIError: {exception_provider} - {message}", - llm_provider=custom_llm_provider, - model=model, - request=getattr(original_exception, "request", None), - litellm_debug_info=extra_information, - ) elif original_exception.status_code == 404: raise NotFoundError( message=f"NotFoundError: {exception_provider} - {message}", diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 176310f7e40..9263558edb3 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -303,6 +303,10 @@ def _build_banned_observability_params() -> frozenset[str]: ) +# Credential selectors that name an account stored on the proxy host, so no +# client-side opt-in can hand them to a caller. Only the deployment config sets them. +_OPERATOR_ONLY_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ("xai_oauth_token_file",) + _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "api_base", "base_url", @@ -327,11 +331,7 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( # caller-supplied value is the same exfil shape as # ``aws_web_identity_token`` on the Bedrock path. "azure_ad_token", - # xAI SuperGrok OAuth token file. The xAI transformer reads - # ``xai_oauth_token_file`` and authenticates as that local account, - # so a caller-supplied path is the same credential-selector shape as - # ``aws_profile_name`` on the Bedrock path. - "xai_oauth_token_file", + *_OPERATOR_ONLY_REQUEST_BODY_PARAMS, # Endpoint-targeting fields that retarget the outbound request or # an observability callback. An attacker-controlled value either # exfiltrates the request payload (incl. messages + admin-set @@ -391,6 +391,12 @@ def _check_banned_params( Shared between the root-level check and the nested-config check so a new banned param only needs to be added in one place. """ + operator_only: Final = next((param for param in _OPERATOR_ONLY_REQUEST_BODY_PARAMS if param in body), None) + if operator_only is not None: + raise ValueError( + f"Rejected Request: {operator_only} is not allowed in request body. " + "Set it on the deployment's litellm_params in your proxy config.yaml instead." + ) for param in _BANNED_REQUEST_BODY_PARAMS: if param not in body: continue diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index bdd9b88382a..db42abdafe3 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -3945,47 +3945,56 @@ class TestIsRequestBodySafeBlocksAwsIdentitySelectors: class TestIsRequestBodySafeBlocksXaiOauthTokenFile: - """A caller must not select another local xAI OAuth account. Router kwargs - override deployment params, so ``xai_oauth_token_file`` in the request body - would authenticate as any token file readable on the proxy host. + """``xai_oauth_token_file`` picks which OAuth account stored on the proxy host signs the + request, and router kwargs override deployment params, so no client-side opt-in may unlock it """ - def test_xai_oauth_token_file_is_in_banned_set(self): - from litellm.proxy.auth.auth_utils import _BANNED_REQUEST_BODY_PARAMS - - assert "xai_oauth_token_file" in set(_BANNED_REQUEST_BODY_PARAMS) - - def test_xai_oauth_token_file_in_request_body_is_rejected(self): + @pytest.mark.parametrize( + "request_body", + [ + {"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}, + {"model": "grok-4", "extra_body": {"xai_oauth_token_file": "auth-bob.json"}}, + {"model": "grok-4", "metadata": {"xai_oauth_token_file": "auth-bob.json"}}, + {"model": "grok-4", "fallbacks": [{"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}]}, + ], + ) + def test_rejected_even_under_proxy_wide_opt_in(self, request_body): with pytest.raises(ValueError, match="xai_oauth_token_file"): is_request_body_safe( - request_body={ - "model": "grok-4", - "xai_oauth_token_file": "/etc/passwd", - }, - general_settings={}, + request_body=request_body, + general_settings={"allow_client_side_credentials": True}, llm_router=None, model="grok-4", ) - def test_xai_oauth_token_file_under_extra_body_is_rejected(self): + def test_rejected_even_when_deployment_lists_it_as_clientside_configurable(self): + from litellm import Router + + router = Router( + model_list=[ + { + "model_name": "grok-4", + "litellm_params": { + "model": "xai/grok-4", + "use_xai_oauth": True, + "xai_oauth_token_file": "auth-alice.json", + "configurable_clientside_auth_params": ["xai_oauth_token_file"], + }, + } + ] + ) with pytest.raises(ValueError, match="xai_oauth_token_file"): is_request_body_safe( - request_body={ - "model": "grok-4", - "extra_body": {"xai_oauth_token_file": "/etc/passwd"}, - }, + request_body={"model": "grok-4", "xai_oauth_token_file": "auth-bob.json"}, general_settings={}, - llm_router=None, + llm_router=router, model="grok-4", ) - def test_xai_oauth_token_file_allowed_under_proxy_wide_opt_in(self): + def test_other_banned_params_still_honor_proxy_wide_opt_in(self): assert ( is_request_body_safe( - request_body={ - "model": "grok-4", - "xai_oauth_token_file": "auth-alice.json", - }, + request_body={"model": "grok-4", "api_base": "https://example.invalid/v1"}, general_settings={"allow_client_side_credentials": True}, llm_router=None, model="grok-4",