diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 900e993cadb..7d0ca935cde 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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( diff --git a/tests/unit/proxy/auth/test_auth_utils.py b/tests/unit/proxy/auth/test_auth_utils.py index 58a78aede3c..6a7bbb2f0ce 100644 --- a/tests/unit/proxy/auth/test_auth_utils.py +++ b/tests/unit/proxy/auth/test_auth_utils.py @@ -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", diff --git a/tests/unit/proxy/test_credential_slot_registry.py b/tests/unit/proxy/test_credential_slot_registry.py index 6332038feb8..47566b853a0 100644 --- a/tests/unit/proxy/test_credential_slot_registry.py +++ b/tests/unit/proxy/test_credential_slot_registry.py @@ -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(),