This commit is contained in:
Authensor 2026-09-23 14:41:59 +00:00 • committed by GitHub
commit 0c0768de5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 300 additions and 1 deletions

View file

@ -313,6 +313,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",
@ -1970,7 +1974,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.
@ -2255,6 +2267,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

View file

@ -172,6 +172,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,
@ -3924,6 +3925,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

View file

@ -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": <pattern>}`` 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)

View file

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