diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 39e858240ef..33df65af085 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -389,10 +389,6 @@ 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", @@ -417,7 +413,6 @@ _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", - *_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 @@ -471,6 +466,8 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = ( "github_copilot_host", "user_provider_credentials", "github_copilot_user_session", + # Picks which xAI OAuth account stored on the proxy host signs the call. + "xai_oauth_token_file", # Observability credentials, hosts, and project identifiers: derived # from the canonical ``_supported_callback_params`` allowlist so new # integrations are covered automatically. Sorted for stable iteration @@ -502,12 +499,6 @@ def _check_banned_params( reject_server_owned_wif_params(wif_body) if not manages_deployments: reject_federated_credential_reference(body) - 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/litellm/types/utils.py b/litellm/types/utils.py index 5939a350970..71185541258 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4323,11 +4323,13 @@ def credential_api_key_secret_reference(credential_values: Mapping[str, object]) return {**credential_values, "api_key": reference} +xai_oauth_litellm_params: Final = ("xai_oauth_token_file",) server_owned_wif_litellm_params: Final = ( anthropic_wif_litellm_params + openai_wif_litellm_params + oauth_token_exchange_litellm_params + github_copilot_oauth_litellm_params + + xai_oauth_litellm_params ) secret_bearing_wif_litellm_params: Final = tuple(sorted(WIF_SECRET_BEARING_KEYS)) diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 6adead36301..197efe26883 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -5316,6 +5316,7 @@ class TestIsRequestBodySafeBlocksXaiOauthTokenFile: {"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"}]}, + {"model": "grok-4", "litellm_params": {"xai_oauth_token_file": "auth-bob.json"}}, ], ) def test_rejected_even_under_proxy_wide_opt_in(self, request_body): @@ -5327,6 +5328,12 @@ class TestIsRequestBodySafeBlocksXaiOauthTokenFile: model="grok-4", ) + def test_rejected_in_stored_fallback_targets_and_health_check_params(self): + from litellm.proxy.auth.auth_utils import reject_server_owned_wif_params + + with pytest.raises(ValueError, match="xai_oauth_token_file"): + reject_server_owned_wif_params({"model": "xai/grok-4", "xai_oauth_token_file": "auth-bob.json"}) + def test_rejected_even_when_deployment_lists_it_as_clientside_configurable(self): from litellm import Router diff --git a/tests/unit/proxy/auth/test_banned_params_extra_body.py b/tests/unit/proxy/auth/test_banned_params_extra_body.py index 6c621b03cba..e87b206a40a 100644 --- a/tests/unit/proxy/auth/test_banned_params_extra_body.py +++ b/tests/unit/proxy/auth/test_banned_params_extra_body.py @@ -28,7 +28,6 @@ from litellm.proxy.auth.auth_utils import is_request_body_safe # noqa: E402 "base_url", "vertex_credentials", "azure_ad_token", - "xai_oauth_token_file", ], ) def test_banned_param_under_extra_body_is_rejected(banned_param): diff --git a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py index 303b8e060ed..7316decb188 100644 --- a/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_model_management_endpoints.py @@ -8989,14 +8989,21 @@ class TestNonAdminCannotPersistWifFieldsOnModel: mock_prisma.db.litellm_proxymodeltable.update.assert_not_called() @pytest.mark.asyncio - async def test_patch_model_non_admin_cannot_set_openai_wif_field(self): + @pytest.mark.parametrize( + ("model", "field", "value"), + [ + ("openai/gpt-4o-mini", "openai_identity_token_file", "/var/run/secrets/tokens/attacker"), + ("xai/grok-4", "xai_oauth_token_file", "auth-other-account.json"), + ], + ) + async def test_patch_model_non_admin_cannot_set_server_owned_token_file(self, model: str, field: str, value: str): from litellm.proxy.management_endpoints.model_management_endpoints import ( patch_model, ) non_admin = UserAPIKeyAuth(user_id="team_admin", user_role=LitellmUserRoles.INTERNAL_USER) existing_row = MagicMock() - existing_row.litellm_params = {"model": "openai/gpt-4o-mini"} + existing_row.litellm_params = {"model": model} existing_row.model_dump.return_value = { "model_name": "gpt", "litellm_params": existing_row.litellm_params, @@ -9033,14 +9040,10 @@ class TestNonAdminCannotPersistWifFieldsOnModel: ) as exc_info: await patch_model( model_id="m1", - patch_data=updateDeployment( - litellm_params=updateLiteLLMParams( - openai_identity_token_file="/var/run/secrets/tokens/attacker", - ) - ), + patch_data=updateDeployment(litellm_params=updateLiteLLMParams.model_validate({field: value})), user_api_key_dict=non_admin, ) - assert getattr(exc_info.value, "param", "") == "openai_identity_token_file" + assert getattr(exc_info.value, "param", "") == field mock_prisma.db.litellm_proxymodeltable.update.assert_not_called() @pytest.mark.asyncio diff --git a/tests/unit/types/test_router.py b/tests/unit/types/test_router.py index 2892a829eab..430d06998cd 100644 --- a/tests/unit/types/test_router.py +++ b/tests/unit/types/test_router.py @@ -27,6 +27,7 @@ from litellm.types.utils import ( oauth_token_exchange_litellm_params, openai_wif_litellm_params, server_owned_wif_litellm_params, + xai_oauth_litellm_params, ) @@ -367,6 +368,7 @@ def test_server_owned_registry_includes_anthropic_openai_and_oauth_token_exchang + openai_wif_litellm_params + oauth_token_exchange_litellm_params + github_copilot_oauth_litellm_params + + xai_oauth_litellm_params ) assert set(openai_wif_litellm_params) == { "openai_identity_provider_id",