fix(xai): register xai_oauth_token_file as a server-owned param

Route the token file through the shared server-owned gate so it is refused in
body litellm_params, stored key/team fallback targets and health check params,
and so only proxy admins can set it on a deployment. Drops the parallel
operator-only check this replaces
This commit is contained in:
hx 2026-10-15 19:40:08 +08:00
parent 97eb891c17
commit 09d5ae5645
6 changed files with 24 additions and 20 deletions

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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",