fix(proxy): stop request body fields from conflicting with admin-set deployment fields (#51254)

This commit is contained in:
shrey 2026-11-11 15:39:17 -08:00 • committed by GitHub
parent a6ce922f07
commit 8ee4ce8418
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 391 additions and 6 deletions

View file

@ -23,6 +23,10 @@ from litellm.constants import (
MINIMUM_CUSTOM_KEY_LENGTH,
STANDARD_CUSTOMER_ID_HEADERS,
)
from litellm.litellm_core_utils.clientside_credential_handler import (
_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, # pyright: ignore[reportPrivateUsage] # one canonical list of deployment credential fields
clientside_credential_keys,
)
from litellm.litellm_core_utils.cloud_storage_security import REQUEST_S3_BUCKET_PARAMS, STORED_S3_BUCKET_PARAM
from litellm.litellm_core_utils.credential_accessor import CredentialAccessor, reject_api_key_secret_reference
from litellm.litellm_core_utils.get_llm_provider_logic import inferred_provider
@ -54,7 +58,12 @@ from litellm.types.router import (
server_owned_wif_fields_present,
)
from litellm.types.router import reject_server_owned_wif_params as _reject_server_owned_wif_params
from litellm.types.utils import CustomPricingLiteLLMParams, LlmProviders, oauth_token_exchange_litellm_params
from litellm.types.utils import (
OIDC_SECRET_REFERENCE_PREFIX,
CustomPricingLiteLLMParams,
LlmProviders,
oauth_token_exchange_litellm_params,
)
from litellm.types.videos.main import DecodedVideoId
@ -380,6 +389,37 @@ def _without_route_named_vertex_target(request_body: Mapping[str, object], route
return MappingProxyType({key: value for key, value in request_body.items() if key not in VERTEX_TARGET_PARAMS})
_AZURE_ENTRA_CREDENTIAL_PARAMS: Final[tuple[str, ...]] = (
"tenant_id",
"client_id",
"client_secret",
"azure_username",
"azure_password",
"azure_scope",
)
_MCP_OAUTH_CLIENT_FORM_FIELDS: Final[frozenset[str]] = frozenset(("client_id", "client_secret"))
_TRANSCRIPTION_SESSION_ROUTES: Final[frozenset[str]] = frozenset(
(
"/v1/realtime/transcription_sessions",
"/realtime/transcription_sessions",
"/openai/v1/realtime/transcription_sessions",
)
)
_TRANSCRIPTION_SESSION_FIELDS: Final[frozenset[str]] = frozenset(("client_secret",))
_TOKEN_INTROSPECTION_ROUTE: Final = "/introspect"
_BANNED_PARAMS_ALLOWED_IN_METADATA: Final[frozenset[str]] = VERTEX_TARGET_PARAMS | frozenset(
_AZURE_ENTRA_CREDENTIAL_PARAMS
)
def _entra_params_owned_by_route(route: str | None) -> frozenset[str]:
if route in _TRANSCRIPTION_SESSION_ROUTES:
return _TRANSCRIPTION_SESSION_FIELDS
if route_manages_mcp_servers(route) or route == _TOKEN_INTROSPECTION_ROUTE:
return _MCP_OAUTH_CLIENT_FORM_FIELDS
return frozenset()
_NESTED_CONFIG_KEYS: Final[tuple[str, ...]] = ("litellm_embedding_config", "extra_body")
# Metadata containers that carry per-request configuration consumed by the
@ -483,6 +523,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",
*_AZURE_ENTRA_CREDENTIAL_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
@ -548,8 +589,34 @@ _BANNED_REQUEST_BODY_PARAMS: Final[tuple[str, ...]] = (
*sorted(CustomPricingLiteLLMParams.model_fields.keys()),
)
_SECRET_REFERENCE_PREFIXES: Final[tuple[str, ...]] = ("os.environ/", OIDC_SECRET_REFERENCE_PREFIX)
_SECRET_RESOLVING_PARAMS: Final[frozenset[str]] = frozenset(
(*_BANNED_REQUEST_BODY_PARAMS, *_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE, *clientside_credential_keys)
)
def _reject_secret_references(body: Mapping[str, object]) -> None:
_reject_secret_reference_in(MappingProxyType({k: v for k, v in body.items() if k in _SECRET_RESOLVING_PARAMS}))
def _reject_secret_reference_in(params: Mapping[str, object]) -> None:
referencing: Final = next(
(
param
for param, value in params.items()
if isinstance(value, str) and value.startswith(_SECRET_REFERENCE_PREFIXES)
),
None,
)
if referencing is not None:
raise ValueError(
f"Rejected Request: {referencing} holds a secret reference, which only a deployment or credential a "
"proxy admin configures can carry."
)
_BANNED_METADATA_PARAMS: Final[tuple[str, ...]] = tuple(
param for param in _BANNED_REQUEST_BODY_PARAMS if param not in VERTEX_TARGET_PARAMS
param for param in _BANNED_REQUEST_BODY_PARAMS if param not in _BANNED_PARAMS_ALLOWED_IN_METADATA
)
@ -707,6 +774,8 @@ def _check_banned_params(
*,
manages_deployments: bool = False,
mcp_server_fields_allowed: bool = False,
route_owned_params: frozenset[str] = frozenset(),
rejects_secret_references: bool = True,
banned_params: tuple[str, ...] = _BANNED_REQUEST_BODY_PARAMS,
opt_in_groups: Callable[[], _OptInGroups],
defer_model_checks: bool = False,
@ -726,9 +795,13 @@ def _check_banned_params(
reject_server_owned_wif_params(wif_body)
if not manages_deployments:
reject_federated_credential_reference(body)
if rejects_secret_references:
_reject_secret_references(body)
for param in banned_params:
if param not in body:
continue
if param in route_owned_params:
continue
if general_settings.get("allow_client_side_credentials") is True:
# Proxy-wide opt-in: every banned param is permitted, exit
# entirely so the rest of the loop doesn't waste work.
@ -951,6 +1024,7 @@ def is_request_body_safe(
llm_router,
manages_deployments=manages_deployments,
mcp_server_fields_allowed=route_manages_mcp_servers(route),
route_owned_params=_entra_params_owned_by_route(route),
opt_in_groups=opt_in_groups,
defer_model_checks=defer_model_checks,
defer_s3_buckets=defer_s3_buckets,
@ -976,6 +1050,7 @@ def is_request_body_safe(
llm_router,
manages_deployments=manages_deployments,
banned_params=_BANNED_METADATA_PARAMS,
rejects_secret_references=False,
opt_in_groups=opt_in_groups,
defer_model_checks=defer_model_checks,
defer_s3_buckets=defer_s3_buckets,
@ -987,6 +1062,7 @@ def is_request_body_safe(
llm_router,
manages_deployments=manages_deployments,
banned_params=_BANNED_METADATA_PARAMS,
rejects_secret_references=False,
opt_in_groups=opt_in_groups,
defer_model_checks=defer_model_checks,
defer_s3_buckets=defer_s3_buckets,
@ -1020,15 +1096,30 @@ def is_request_body_safe(
llm_router,
manages_deployments=manages_deployments,
banned_params=_BANNED_METADATA_PARAMS,
rejects_secret_references=False,
opt_in_groups=opt_in_groups,
defer_model_checks=defer_model_checks,
defer_s3_buckets=defer_s3_buckets,
)
for user_config_params in _user_config_litellm_params(request_body):
reject_api_key_secret_reference(user_config_params)
_reject_user_config_secret_references(user_config_params)
return True
def _reject_user_config_secret_references(litellm_params: Mapping[str, object]) -> None:
for params in (litellm_params, *_nested_configs(litellm_params), *_data_source_parameters(litellm_params)):
_reject_secret_reference_in(params)
def _data_source_parameters(litellm_params: Mapping[str, object]) -> tuple[Mapping[str, object], ...]:
return tuple(
parameters
for source in _mappings_in(litellm_params.get("dataSources"))
if (parameters := _coerce_metadata_to_dict(source.get("parameters"))) is not None
)
def _reject_request_credential_params(litellm_params: Mapping[str, object], *, manages_deployments: bool) -> None:
if manages_deployments:
reject_server_owned_wif_fields(

View file

@ -59,7 +59,7 @@ def test_token_exchange_settings_in_request_body_are_rejected(field: str) -> Non
match="server-owned workload identity federation or OAuth token exchange parameter",
) as error:
is_request_body_safe(
request_body={"model": "microsoft_365_copilot/chat", field: "attacker-chosen"},
request_body={"model": "microsoft_365_copilot/chat", field: "caller-chosen"},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="microsoft_365_copilot/chat",
@ -151,6 +151,302 @@ def test_model_opt_in_cannot_allow_a_token_exchange_endpoint_in_a_request_body()
)
_AZURE_ENTRA_CREDENTIAL_PARAMS: Final = (
"tenant_id",
"client_id",
"client_secret",
"azure_username",
"azure_password",
"azure_scope",
)
def _azure_entra_router(configurable_clientside_auth_params: tuple[str, ...] = ()) -> Router:
return Router(
model_list=[
{
"model_name": "azure-group",
"litellm_params": {
"model": "azure/gpt-4o",
"api_base": "https://admin-endpoint.example.com",
"tenant_id": "admin-tenant",
"client_id": "admin-client",
"client_secret": "admin-secret",
"configurable_clientside_auth_params": list(configurable_clientside_auth_params),
},
}
]
)
@pytest.mark.parametrize("param", _AZURE_ENTRA_CREDENTIAL_PARAMS)
@pytest.mark.parametrize("container", (None, "extra_body", "litellm_embedding_config"))
def test_azure_entra_credential_params_in_request_body_are_rejected(param: str, container: str | None) -> None:
override: Final = {param: "caller-chosen"}
with pytest.raises(ValueError, match=f"{param} is not allowed in request body"):
is_request_body_safe(
request_body={"model": "azure-group", **({container: override} if container else override)},
general_settings={},
llm_router=_azure_entra_router(),
model="azure-group",
route="/v1/chat/completions",
)
@pytest.mark.parametrize("param", _AZURE_ENTRA_CREDENTIAL_PARAMS)
@pytest.mark.parametrize("metadata_key", ("metadata", "litellm_metadata"))
def test_azure_entra_credential_names_are_allowed_as_request_metadata(param: str, metadata_key: str) -> None:
assert (
is_request_body_safe(
request_body={"model": "azure-group", metadata_key: {param: "caller-tag"}},
general_settings={},
llm_router=_azure_entra_router(),
model="azure-group",
route="/v1/chat/completions",
)
is True
)
def test_deployment_opt_in_allows_only_the_named_azure_entra_credential_param() -> None:
router: Final = _azure_entra_router(configurable_clientside_auth_params=("client_id",))
assert (
is_request_body_safe(
request_body={"model": "azure-group", "client_id": "caller-client"},
general_settings={},
llm_router=router,
model="azure-group",
route="/v1/chat/completions",
)
is True
)
with pytest.raises(ValueError, match="client_secret"):
is_request_body_safe(
request_body={"model": "azure-group", "client_id": "caller-client", "client_secret": "caller-secret"},
general_settings={},
llm_router=router,
model="azure-group",
route="/v1/chat/completions",
)
@pytest.mark.parametrize("param", (*_AZURE_ENTRA_CREDENTIAL_PARAMS, "api_key", "aws_access_key_id", "azure_ad_token"))
@pytest.mark.parametrize("reference", ("os.environ/LITELLM_MASTER_KEY", "oidc/env/LITELLM_MASTER_KEY"))
@pytest.mark.parametrize("container", (None, "extra_body"))
def test_secret_reference_in_a_credential_param_is_rejected_despite_client_side_opt_in(
param: str, reference: str, container: str | None
) -> None:
override: Final = {param: reference}
with pytest.raises(ValueError, match=f"{param} holds an? (oidc/ )?secret reference"):
is_request_body_safe(
request_body={"model": "azure-group", **({container: override} if container else override)},
general_settings={"allow_client_side_credentials": True},
llm_router=_azure_entra_router(),
model="azure-group",
route="/v1/chat/completions",
)
def test_secret_reference_is_rejected_for_a_param_the_deployment_opted_in() -> None:
with pytest.raises(ValueError, match="client_id holds a secret reference"):
is_request_body_safe(
request_body={"model": "azure-group", "client_id": "os.environ/LITELLM_MASTER_KEY"},
general_settings={},
llm_router=_azure_entra_router(configurable_clientside_auth_params=("client_id",)),
model="azure-group",
route="/v1/chat/completions",
)
@pytest.mark.parametrize(
"body",
(
{"prompt": "os.environ/PATH is what in Python?"},
{"input": "oidc/env/ tokens explained"},
{"api_key": "sk-caller-own-key"},
{"client_id": "an os.environ/ mention that is not a prefix"},
),
)
def test_values_that_are_not_secret_references_in_credential_params_are_allowed(body: Mapping[str, str]) -> None:
assert (
is_request_body_safe(
request_body={"model": "azure-group", **body},
general_settings={"allow_client_side_credentials": True},
llm_router=_azure_entra_router(),
model="azure-group",
route="/v1/chat/completions",
)
is True
)
def test_deployment_management_body_may_carry_a_secret_reference() -> None:
assert (
is_request_body_safe(
request_body={"model_name": "azure-group", "api_key": "os.environ/AZURE_API_KEY"},
general_settings={},
llm_router=None,
model="",
route="/model/new",
)
is True
)
@pytest.mark.parametrize("field", ("client_id", "client_secret"))
def test_mcp_oauth_token_form_may_carry_oauth_client_fields(field: str) -> None:
assert (
is_request_body_safe(
request_body={"grant_type": "authorization_code", "code": "abc", field: "mcp-oauth-client"},
general_settings={},
llm_router=None,
model="",
route="/v1/mcp/server/oauth/abc123/token",
)
is True
)
@pytest.mark.parametrize("field", ("tenant_id", "azure_username", "azure_password", "azure_scope"))
def test_mcp_server_routes_still_reject_non_oauth_azure_entra_params(field: str) -> None:
with pytest.raises(ValueError, match=field):
is_request_body_safe(
request_body={"grant_type": "authorization_code", "code": "abc", field: "caller-chosen"},
general_settings={},
llm_router=None,
model="",
route="/v1/mcp/server/oauth/abc123/token",
)
@pytest.mark.parametrize("body", ({"client_id": "gateway-1"}, {"client_id": "gateway-1", "client_secret": "gw-secret"}))
def test_token_introspection_form_may_carry_oauth_client_fields(body: Mapping[str, str]) -> None:
assert (
is_request_body_safe(
request_body={"token": "llm_session_abc", **body},
general_settings={},
llm_router=None,
model="",
route="/introspect",
)
is True
)
@pytest.mark.parametrize("body", ({"tenant_id": "caller-tenant"}, {"client_id": "os.environ/LITELLM_MASTER_KEY"}))
def test_token_introspection_still_rejects_other_entra_params_and_secret_references(body: Mapping[str, str]) -> None:
with pytest.raises(ValueError, match=next(iter(body))):
is_request_body_safe(
request_body={"token": "llm_session_abc", **body},
general_settings={},
llm_router=None,
model="",
route="/introspect",
)
_TRANSCRIPTION_SESSION_ROUTES: Final = (
"/v1/realtime/transcription_sessions",
"/realtime/transcription_sessions",
"/openai/v1/realtime/transcription_sessions",
)
@pytest.mark.parametrize("route", _TRANSCRIPTION_SESSION_ROUTES)
def test_transcription_session_body_may_carry_its_client_secret_config(route: str) -> None:
assert (
is_request_body_safe(
request_body={
"model": "gpt-4o-transcribe",
"client_secret": {"expires_at": {"anchor": "created_at", "seconds": 600}},
},
general_settings={},
llm_router=None,
model="",
route=route,
)
is True
)
@pytest.mark.parametrize("route", _TRANSCRIPTION_SESSION_ROUTES)
@pytest.mark.parametrize("body", ({"tenant_id": "caller-tenant"}, {"client_secret": "os.environ/LITELLM_MASTER_KEY"}))
def test_transcription_session_routes_still_reject_other_entra_params_and_secret_references(
route: str, body: Mapping[str, str]
) -> None:
with pytest.raises(ValueError, match=next(iter(body))):
is_request_body_safe(
request_body={"model": "gpt-4o-transcribe", **body},
general_settings={},
llm_router=None,
model="",
route=route,
)
@pytest.mark.parametrize("param", _AZURE_ENTRA_CREDENTIAL_PARAMS)
@pytest.mark.parametrize("metadata_key", ("metadata", "litellm_metadata"))
@pytest.mark.parametrize("value", ("os.environ/HOME", "oidc/azure/team-a"))
def test_metadata_tags_whose_value_looks_like_a_secret_reference_are_allowed(
param: str, metadata_key: str, value: str
) -> None:
assert (
is_request_body_safe(
request_body={"model": "azure-group", metadata_key: {param: value}},
general_settings={},
llm_router=_azure_entra_router(),
model="azure-group",
route="/v1/chat/completions",
)
is True
)
_USER_CONFIG_SECRET_REFERENCE_PARAMS: Final = (
{"model": "openai/gpt-4o", "api_base": "https://caller.example.com", "api_key": "os.environ/LITELLM_MASTER_KEY"},
{"model": "openai/gpt-4o", "organization": "os.environ/LITELLM_MASTER_KEY"},
{"model": "openai/gpt-4o", "extra_body": {"api_key": "oidc/env/LITELLM_MASTER_KEY"}},
{"model": "azure/gpt-4o", "dataSources": [{"parameters": {"key": "os.environ/LITELLM_MASTER_KEY"}}]},
)
def _user_config(slot: str, litellm_params: Mapping[str, object]) -> Mapping[str, object]:
if slot == "default_litellm_params":
return {"default_litellm_params": dict(litellm_params)}
return {"model_list": [{"model_name": "caller-model", "litellm_params": dict(litellm_params)}]}
@pytest.mark.parametrize("litellm_params", _USER_CONFIG_SECRET_REFERENCE_PARAMS)
@pytest.mark.parametrize("slot", ("model_list", "default_litellm_params"))
def test_user_config_secret_references_are_rejected_despite_client_side_opt_in(
litellm_params: Mapping[str, object], slot: str
) -> None:
with pytest.raises(ValueError, match="holds a secret reference"):
is_request_body_safe(
request_body={"model": "caller-model", "user_config": _user_config(slot, litellm_params)},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="caller-model",
route="/v1/chat/completions",
)
def test_user_config_with_literal_credentials_is_allowed_with_client_side_opt_in() -> None:
litellm_params: Final = {"model": "openai/gpt-4o", "api_base": "https://caller.example.com", "api_key": "sk-own"}
assert (
is_request_body_safe(
request_body={"model": "caller-model", "user_config": _user_config("model_list", litellm_params)},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="caller-model",
route="/v1/chat/completions",
)
is True
)
@pytest.mark.parametrize(
"param",
sorted(ANTHROPIC_WIF_KWARGS_KEYS | OPENAI_WIF_KWARGS_KEYS | set(oauth_token_exchange_litellm_params)),
@ -5821,7 +6117,7 @@ def test_per_user_credential_slots_in_request_body_are_rejected(field: str) -> N
connection by proxy-injected secret_fields; a body-supplied value could only spoof either."""
with pytest.raises(ValueError, match="Rejected Request") as error:
is_request_body_safe(
request_body={"model": "github_copilot/gpt-4o", field: "attacker-chosen"},
request_body={"model": "github_copilot/gpt-4o", field: "caller-chosen"},
general_settings={},
llm_router=None,
model="github_copilot/gpt-4o",

View file

@ -147,8 +147,6 @@ REQUEST_BODY_PARAM_CLASSIFICATION: Final[Mapping[str, Classification]] = Mapping
"aws_access_key_id": Unplanted(),
"aws_secret_access_key": Unplanted(),
"aws_session_token": Unplanted(),
"azure_password": Unplanted(),
"client_secret": Unplanted(),
"s3_access_key_id": Unplanted(),
"s3_secret_access_key": Unplanted(),
"valkey_password": Unplanted(),