chore(proxy): clean up request parameter validation and provider destination handling (#34189)

(cherry picked from commit 065faf6e69)

Backport adaptation for stable/1.91.x:
- litellm/proxy/auth/user_api_key_auth.py: dropped `Iterator` from the typing
  import as upstream does, but kept this line's import shape rather than taking
  `Protocol`, which reached staging with a feature this line does not carry and
  is unused here.
- tests/code_coverage_tests/recursive_detector.py: added only this commit's own
  `_iter_fallback_targets` allowlist entry; the three neighbouring entries guard
  staging-prior recursive helpers absent from this line.
- tests/test_litellm/proxy/auth/test_auth_utils.py: kept all four test classes
  this commit adds (TestClientsideBaseOverrideOutboundKey,
  TestIsRequestBodySafeBlocksFallbackSmuggle,
  TestIsRequestBodySafeRejectsUrlValuedFallback,
  TestIsRequestBodySafeBlocksVertexCredentialAlias) and omitted staging-prior
  tests for nvcf_function_id, use_ssl and bedrock_tags, whose subjects are not
  on this line.
This commit is contained in:
yucheng-berri 2026-07-21 17:57:58 -07:00 committed by Yuneng Jiang
parent 08429626f9
commit 8498dc0bb8
No known key found for this signature in database
13 changed files with 700 additions and 104 deletions

View file

@ -148,6 +148,15 @@ def _parse_url_destination_allowlist_entry(
return _normalize_host(parsed.hostname), scheme, port
def provider_url_destination_candidates(value: str) -> Tuple[str, ...]:
return tuple(
candidate
for part in value.split(",")
for candidate in (part.strip(), part.strip().split("/", 1)[1] if "/" in part.strip() else "")
if candidate
)
def is_url_destination_allowed_by_host(url: str, allowed_hosts: List[str]) -> bool:
"""Return True when a credential-bearing provider URL is admin-allowlisted.

View file

@ -322,7 +322,7 @@ class HuggingFaceEmbedding(BaseLLM):
task = get_hf_task_embedding_for_model(model=model, task_type=task_type, api_base=HF_HUB_URL)
# print_verbose(f"{model}, {task}")
embed_url = ""
if "https" in model:
if model.startswith(("http://", "https://")):
embed_url = model
elif api_base:
embed_url = api_base

View file

@ -316,25 +316,6 @@ class HuggingFaceEmbeddingConfig(BaseConfig):
return data
def get_api_base(self, api_base: Optional[str], model: str) -> str:
"""
Get the API base for the Huggingface API.
Do not add the chat/embedding/rerank extension here. Let the handler do this.
"""
if "https" in model:
completion_url = model
elif api_base is not None:
completion_url = api_base
elif "HF_API_BASE" in os.environ:
completion_url = os.getenv("HF_API_BASE", "")
elif "HUGGINGFACE_API_BASE" in os.environ:
completion_url = os.getenv("HUGGINGFACE_API_BASE", "")
else:
completion_url = f"https://api-inference.huggingface.co/models/{model}"
return completion_url
def validate_environment(
self,
headers: Dict,

View file

@ -34,7 +34,7 @@ def completion(
optional_params=optional_params,
litellm_params=litellm_params,
)
if "https" in model:
if model.startswith(("http://", "https://")):
completion_url = model
elif api_base:
completion_url = api_base
@ -96,7 +96,7 @@ def embedding(
encoding=None,
):
# Create completion URL
if "https" in model:
if model.startswith(("http://", "https://")):
embeddings_url = model
elif api_base:
embeddings_url = f"{api_base}/v1/embeddings"

View file

@ -3,7 +3,7 @@ import re
import sys
from functools import lru_cache
from logging import Logger
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple, Union
from typing import Any, Dict, FrozenSet, Iterator, List, Mapping, Optional, Tuple, Union
from fastapi import HTTPException, Request, status
@ -12,7 +12,12 @@ from litellm import Router, provider_list
from litellm._logging import verbose_proxy_logger
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.litellm_core_utils.url_utils import (
SSRFError,
is_url_destination_allowed_by_host,
provider_url_destination_candidates,
validate_url,
)
from litellm.proxy._types import *
from litellm.types.router import CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS
from litellm.types.utils import CustomPricingLiteLLMParams
@ -280,6 +285,7 @@ _BANNED_REQUEST_BODY_PARAMS: Tuple[str, ...] = (
"deployment_url",
# SDK-only field; also rejected outright in is_request_body_safe.
"model_list",
"vertex_ai_credentials",
# Observability credentials, hosts, and project identifiers: derived
# from the canonical ``_supported_callback_params`` allowlist so new
# integrations are covered automatically. Sorted for stable iteration
@ -332,6 +338,60 @@ def _check_banned_params(
)
_FALLBACK_FIELDS: tuple[str, ...] = (
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
)
def _iter_fallback_field_values(request_body: Mapping[str, object]) -> Iterator[object]:
override = request_body.get("router_settings_override")
for source in (request_body, override):
if isinstance(source, Mapping):
for field in _FALLBACK_FIELDS:
yield source.get(field)
def _iter_fallback_targets(value: object, depth: int) -> Iterator[str | Mapping[str, object]]:
if depth > 2 * litellm.ROUTER_MAX_FALLBACKS:
raise ValueError("Rejected Request: fallback nesting exceeds the allowed validation depth.")
if not isinstance(value, list):
return
for item in value:
if isinstance(item, str):
yield item
elif isinstance(item, Mapping):
values = tuple(item.values())
if not (values and all(isinstance(v, list) for v in values)):
yield item
if isinstance(item.get("model"), str):
for field in _FALLBACK_FIELDS:
yield from _iter_fallback_targets(item.get(field), depth + 1)
else:
for target_list in values:
yield from _iter_fallback_targets(target_list, depth + 1)
def iter_request_fallback_targets(request_body: Mapping[str, object]) -> Iterator[str | Mapping[str, object]]:
for value in _iter_fallback_field_values(request_body):
yield from _iter_fallback_targets(value, 0)
def _reject_url_valued_fallback_target(value: str) -> None:
allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise ValueError(
f"Rejected Request: URL-valued fallback destination '{value}' is not allowed. "
"Configure custom endpoints with api_base instead, or add the destination host to "
"`provider_url_destination_allowed_hosts` in litellm_settings."
)
def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: Optional[Router], model: str) -> bool:
"""
Check if the request body is safe.
@ -369,6 +429,14 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router:
metadata = _coerce_metadata_to_dict(request_body.get(metadata_key))
if metadata is not None:
_check_banned_params(metadata, general_settings, llm_router, model)
for target in iter_request_fallback_targets(request_body):
if isinstance(target, dict):
_check_banned_params(target, general_settings, llm_router, model)
target_model = target.get("model")
if isinstance(target_model, str):
_reject_url_valued_fallback_target(target_model)
elif isinstance(target, str):
_reject_url_valued_fallback_target(target)
litellm_params = _coerce_metadata_to_dict(request_body.get("litellm_params"))
if litellm_params is not None:
litellm_params_metadata = _coerce_metadata_to_dict(litellm_params.get("metadata"))

View file

@ -12,7 +12,7 @@ import fnmatch
import re
import secrets
from datetime import datetime, timezone
from typing import Any, Dict, Iterator, NamedTuple, List, Optional, Tuple, Union, cast
from typing import Any, Dict, NamedTuple, List, Optional, Tuple, Union, cast
import fastapi
from fastapi import HTTPException, Request, WebSocket, status
@ -55,6 +55,7 @@ from litellm.proxy.auth.auth_utils import (
get_model_from_request,
get_request_route,
get_request_route_template,
iter_request_fallback_targets,
normalize_request_route,
pre_db_read_auth_checks,
route_in_additonal_public_routes,
@ -2694,19 +2695,11 @@ async def _enforce_key_and_fallback_model_access(
llm_router=llm_router,
)
# Validate every fallback model name reachable by this request.
# All three fields (``fallbacks``, ``context_window_fallbacks``,
# ``content_policy_fallbacks``) are forwarded to the router as
# per-request kwargs whether they appear at the top level of
# ``request_data`` or nested under ``router_settings_override``.
# Both surfaces must be validated against the API key's model
# allowlist or a caller can smuggle a restricted model. VERIA-44.
fallback_names: List[str] = []
override_settings = request_data.get("router_settings_override")
for _fb_key in ROUTER_FALLBACK_FIELDS:
fallback_names.extend(iter_router_fallback_model_names(request_data.get(_fb_key)))
if isinstance(override_settings, dict):
fallback_names.extend(iter_router_fallback_model_names(override_settings.get(_fb_key)))
fallback_names = tuple(
name
for target in iter_request_fallback_targets(request_data)
if (name := _fallback_target_model_name(target)) is not None
)
for _name in dict.fromkeys(fallback_names): # dedupe, preserve order
await can_key_call_model(
@ -2722,36 +2715,14 @@ async def _enforce_key_and_fallback_model_access(
)
ROUTER_FALLBACK_FIELDS: Tuple[str, ...] = (
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
)
def iter_router_fallback_model_names(fallbacks: Any) -> Iterator[str]:
"""Yield leaf model names from any of the supported fallbacks shapes.
Handles the simple top-level shape (``str`` or ``{"model": str}``) and
the nested router-config shape (``[{primary: [fallback_list]}]``).
"""
if not isinstance(fallbacks, list):
return
for entry in fallbacks:
if isinstance(entry, str):
yield entry
elif isinstance(entry, dict):
if isinstance(entry.get("model"), str):
yield entry["model"]
continue
for fallback_list in entry.values():
if not isinstance(fallback_list, list):
continue
for m in fallback_list:
if isinstance(m, str):
yield m
elif isinstance(m, dict) and isinstance(m.get("model"), str):
yield m["model"]
def _fallback_target_model_name(target: object) -> str | None:
if isinstance(target, str):
return target
if isinstance(target, dict):
model = target.get("model")
if isinstance(model, str):
return model
return None
async def _run_post_custom_auth_checks(

View file

@ -19,7 +19,10 @@ from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
iter_client_callback_metadata_dicts,
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
from litellm.litellm_core_utils.url_utils import (
is_url_destination_allowed_by_host,
provider_url_destination_candidates,
)
from litellm.proxy._types import (
AddTeamCallback,
CommonProxyErrors,
@ -225,23 +228,26 @@ def _reject_url_valued_destinations(data: Dict[str, Any]) -> None:
allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for field in _URL_DESTINATION_REQUEST_FIELDS:
value = data.get(field)
if not isinstance(value, str) or not value.startswith(("http://", "https://")):
if not isinstance(value, str):
continue
if is_url_destination_allowed_by_host(value, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
def _strip_untrusted_request_header_controls(

View file

@ -52,6 +52,7 @@ IGNORE_FUNCTIONS = [
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
"_freeze_for_dedupe", # OTEL: max depth set (default 16, _FREEZE_MAX_DEPTH); fails closed by returning repr(value) at the cap.
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
]

View file

@ -121,6 +121,20 @@ class TestHuggingFaceEmbedding:
assert response.usage.prompt_tokens > 0
assert response.usage.total_tokens == response.usage.prompt_tokens
def test_model_name_with_https_substring_uses_api_base(self):
api_base = "https://legit.example/embed"
litellm.embedding(
model="huggingface/my-https-endpoint",
input=["hello world"],
input_type="embed",
api_base=api_base,
)
self.mock_http.assert_called_once()
called_url = self.mock_http.call_args[0][0]
assert called_url == api_base
def test_embedding_with_sentence_similarity_task(self):
"""Test embedding when task type is sentence-similarity (requires 2+ sentences)"""

View file

@ -0,0 +1,55 @@
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath("../../../../.."))
import litellm
MOCK_COMPLETION_RESPONSE = {
"choices": [{"message": {"role": "assistant", "content": "hi there"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
}
def _mock_post_response():
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "ok"
mock_response.json.return_value = MOCK_COMPLETION_RESPONSE
return mock_response
def test_model_name_with_https_substring_uses_api_base():
api_base = "https://legit.example"
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
) as mock_post:
mock_post.return_value = _mock_post_response()
litellm.completion(
model="oobabooga/my-https-model",
messages=[{"role": "user", "content": "hello"}],
api_base=api_base,
)
mock_post.assert_called_once()
called_url = mock_post.call_args[0][0]
assert called_url == f"{api_base}/v1/chat/completions"
def test_url_valued_model_still_targets_that_url():
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
) as mock_post:
mock_post.return_value = _mock_post_response()
litellm.completion(
model="oobabooga/https://sdk-user.example",
messages=[{"role": "user", "content": "hello"}],
)
mock_post.assert_called_once()
called_url = mock_post.call_args[0][0]
assert called_url == "https://sdk-user.example/v1/chat/completions"

View file

@ -1512,7 +1512,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
}
out = get_dynamic_litellm_params(
litellm_params=dict(admin_params),
request_kwargs={"base_url": "https://attacker.example"},
request_kwargs={"base_url": "https://attacker.example", "api_key": "sk-caller"},
)
assert "aws_access_key_id" not in out
assert "aws_secret_access_key" not in out
@ -1540,6 +1540,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-caller",
"organization": "org-attacker",
"extra_body": {"attacker": "value"},
},
@ -1563,6 +1564,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-caller",
"organization": "",
"extra_body": "",
},
@ -1590,6 +1592,310 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
assert out["api_version"] == "2026-04-01"
assert out["api_base"] == "https://admin.upstream/v1"
def test_client_api_key_used_when_supplied_with_base_override(self):
from litellm.router_utils.clientside_credential_handler import (
get_dynamic_litellm_params,
)
out = get_dynamic_litellm_params(
litellm_params={
"model": "gpt-4",
"api_key": "sk-admin-secret",
"api_base": "https://admin.upstream/v1",
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-client-byok",
},
)
assert out["api_key"] == "sk-client-byok"
assert "sk-admin-secret" not in str(out)
_OPENAI_CHAT_RESPONSE = {
"id": "chatcmpl-x",
"object": "chat.completion",
"created": 1,
"model": "gpt-4",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
class TestClientsideBaseOverrideOutboundKey:
"""Drive a completion through the router and assert on the outbound request
when the caller overrides ``api_base``."""
def _router(self):
from litellm import Router
return Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "sk-SERVER-CONFIG",
"api_base": "https://admin.upstream/v1",
},
}
]
)
@pytest.fixture(autouse=True)
def _ambient_server_key(self, monkeypatch):
import litellm
monkeypatch.setenv("OPENAI_API_KEY", "sk-SERVER-ENV")
monkeypatch.setattr(litellm, "api_key", None, raising=False)
def test_caller_key_override_sends_caller_key_never_server_key(self):
import httpx
import respx
with respx.mock:
route = respx.post("https://caller.example/v1/chat/completions").mock(
return_value=httpx.Response(200, json=_OPENAI_CHAT_RESPONSE)
)
self._router().completion(
model="gpt-4",
messages=[{"role": "user", "content": "hi"}],
api_base="https://caller.example/v1",
api_key="sk-CALLER",
)
authorization = route.calls.last.request.headers.get("authorization")
assert authorization == "Bearer sk-CALLER"
assert "SERVER" not in (authorization or "")
def _rounds_deep_api_base_payload(rounds, field):
"""Build a fallbacks payload with ``api_base`` on a target nested ``rounds``
fallback-rounds deep, each round wrapped in its own grouping dict."""
node = {"model": "leaf", "api_base": "https://attacker.example"}
for i in range(rounds):
node = {"model": f"m{i}", field: [{"grp": [node]}]}
return {"model": "gpt-4", field: [{"grp": [node]}]}
class TestIsRequestBodySafeBlocksFallbackSmuggle:
"""``is_request_body_safe`` runs the banned-param check on every dict target
inside the fallback lists."""
@pytest.fixture(autouse=True)
def _disable_url_validation(self, monkeypatch):
import litellm
monkeypatch.setattr(litellm, "user_url_validation", False, raising=False)
@pytest.mark.parametrize(
"fallback_key",
["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"],
)
def test_api_base_smuggled_via_nested_fallback_is_rejected(self, fallback_key):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_key: [
{
"gpt-4": [
{"model": "evil", "api_base": "https://attacker.example"},
]
}
],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_string_only_fallbacks_are_accepted(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": ["gpt-3.5-turbo", "claude-3-haiku"]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_benign_dict_fallback_entry_is_accepted(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": [{"model": "gpt-3.5-turbo"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_smuggled_fallback_allowed_under_proxy_wide_opt_in(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [
{"gpt-4": [{"model": "byok", "api_base": "https://my-byok.example"}]}
],
},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="gpt-4",
)
is True
)
@pytest.mark.parametrize(
"fallback_field",
["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"],
)
@pytest.mark.parametrize("surface", ["top_level", "router_settings_override"])
def test_deeply_nested_api_base_smuggle_rejected_on_both_surfaces(self, fallback_field, surface):
nested = [
{
"always-fail": [
{
"model": "x",
fallback_field: [
{"x": [{"model": "deepseek-chat", "api_base": "http://attacker"}]}
],
}
]
}
]
request_body = {"model": "gpt-4"}
if surface == "top_level":
request_body[fallback_field] = nested
else:
request_body["router_settings_override"] = {fallback_field: nested}
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=request_body,
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_router_settings_override_single_level_api_base_rejected(self):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
"router_settings_override": {
"fallbacks": [{"gpt-4": [{"model": "x", "api_base": "http://attacker"}]}]
},
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_model_less_config_dict_api_base_rejected(self):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": [{"api_base": "http://attacker"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_nested_api_base_caught_across_router_fallback_rounds(self):
"""An ``api_base`` target nested ``ROUTER_MAX_FALLBACKS - 1`` rounds deep
is still reached and rejected."""
import litellm
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=_rounds_deep_api_base_payload(litellm.ROUTER_MAX_FALLBACKS - 1, "fallbacks"),
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_grouping_only_deep_chain_is_rejected_at_depth_limit(self):
"""A deep grouping-only chain (``{"g": [{"g": [...]}]}``) is rejected at the
validation-depth limit rather than accepted or raising RecursionError."""
node: object = ["safe-model"]
for _ in range(5000):
node = [{"grp": node}]
with pytest.raises(ValueError, match="depth"):
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": node},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_pathologically_deep_model_nesting_is_rejected(self):
with pytest.raises(ValueError, match="depth"):
is_request_body_safe(
request_body=_rounds_deep_api_base_payload(5000, "fallbacks"),
general_settings={},
llm_router=None,
model="gpt-4",
)
class TestIsRequestBodySafeRejectsUrlValuedFallback:
@pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"])
def test_url_valued_string_fallback_is_rejected(self, fallback_field):
with pytest.raises(ValueError, match="URL-valued fallback"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_field: [{"gpt-4": ["huggingface/http://attacker.example/path"]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
@pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"])
def test_url_valued_dict_model_fallback_is_rejected(self, fallback_field):
with pytest.raises(ValueError, match="URL-valued fallback"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_field: [{"gpt-4": [{"model": "huggingface/http://attacker.example/path"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_ordinary_string_fallback_is_allowed(self):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": ["gpt-4-backup"]}]},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_ordinary_dict_model_fallback_is_allowed(self):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": [{"model": "gpt-4-backup"}]}]},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
class TestIsRequestBodySafeBlocksEndpointTargetingFields:
"""
@ -1712,6 +2018,46 @@ class TestIsRequestBodySafeBlocksBedrockProjectOverride:
)
class TestIsRequestBodySafeBlocksVertexCredentialAlias:
@pytest.mark.parametrize("field", ["vertex_ai_credentials"])
def test_field_in_request_body_is_rejected(self, field):
with pytest.raises(ValueError, match=field):
is_request_body_safe(
request_body={"model": "gpt-4", field: "attacker-supplied"},
general_settings={},
llm_router=None,
model="gpt-4",
)
@pytest.mark.parametrize("field", ["vertex_ai_credentials"])
def test_admin_opt_in_proxy_wide_allows(self, field):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", field: "byok-supplied"},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="gpt-4",
)
is True
)
def test_legitimate_request_body_param_still_allowed(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"temperature": 0.7,
"max_tokens": 128,
"user": "end-user-123",
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
# ── is_request_body_safe nested-config recursion (VERIA-6) ────────────────────

View file

@ -11,12 +11,22 @@ from unittest.mock import AsyncMock, patch
import pytest
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import iter_request_fallback_targets
from litellm.proxy.auth.user_api_key_auth import (
_enforce_key_and_fallback_model_access,
iter_router_fallback_model_names,
_fallback_target_model_name,
)
def _fallback_model_names(fallbacks):
"""Model names the auth check validates for a top-level ``fallbacks`` value."""
return [
name
for target in iter_request_fallback_targets({"fallbacks": fallbacks})
if (name := _fallback_target_model_name(target)) is not None
]
def _key_with_models(models: List[str]) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
api_key="hashed",
@ -26,37 +36,40 @@ def _key_with_models(models: List[str]) -> UserAPIKeyAuth:
)
# ── iter_router_fallback_model_names ─────────────────────────────────────────
# ── fallback model-name extraction ───────────────────────────────────────────
def testiter_router_fallback_model_names_router_config_shape():
def test_fallback_model_names_router_config_shape():
"""Router-config shape: ``[{primary: [fallback_list]}]``."""
assert list(
iter_router_fallback_model_names(
[{"gpt-3.5-turbo": ["gpt-4", "claude-3"]}, {"gpt-4o": ["o1"]}]
)
assert _fallback_model_names(
[{"gpt-3.5-turbo": ["gpt-4", "claude-3"]}, {"gpt-4o": ["o1"]}]
) == ["gpt-4", "claude-3", "o1"]
def testiter_router_fallback_model_names_simple_string_shape():
def test_fallback_model_names_simple_string_shape():
"""Simple top-level shape: list of strings."""
assert list(iter_router_fallback_model_names(["gpt-4", "claude-3"])) == [
assert _fallback_model_names(["gpt-4", "claude-3"]) == ["gpt-4", "claude-3"]
def test_fallback_model_names_client_side_shape():
"""ClientSideFallbackModel shape: ``[{"model": "..."}]``."""
assert _fallback_model_names([{"model": "gpt-4"}, {"model": "claude-3"}]) == [
"gpt-4",
"claude-3",
]
def testiter_router_fallback_model_names_client_side_shape():
"""ClientSideFallbackModel shape: ``[{"model": "..."}]``."""
assert list(
iter_router_fallback_model_names([{"model": "gpt-4"}, {"model": "claude-3"}])
) == ["gpt-4", "claude-3"]
def test_fallback_model_names_nested_deployment_fallbacks():
"""A deployment target's own nested fallback field is unrolled too."""
assert _fallback_model_names(
[{"primary": [{"model": "gpt-4", "fallbacks": [{"gpt-4": ["deepseek-chat"]}]}]}]
) == ["gpt-4", "deepseek-chat"]
def testiter_router_fallback_model_names_empty_or_none():
assert list(iter_router_fallback_model_names(None)) == []
assert list(iter_router_fallback_model_names([])) == []
assert list(iter_router_fallback_model_names("not a list")) == []
def test_fallback_model_names_empty_or_none():
assert _fallback_model_names(None) == []
assert _fallback_model_names([]) == []
assert _fallback_model_names("not a list") == []
# ── _enforce_key_and_fallback_model_access ────────────────────────────────────
@ -200,6 +213,98 @@ async def test_top_level_fallback_fields_validated(fallback_field):
assert "top-level-smuggled" in seen
@pytest.mark.asyncio
async def test_nested_deployment_fallback_inner_model_validated():
"""A model name nested several fallback rounds deep, inside a deployment
target's own ``fallbacks``, is extracted and passed to can_key_call_model."""
valid_token = _key_with_models(["gpt-3.5-turbo"])
request_data = {
"model": "gpt-3.5-turbo",
"fallbacks": [
{
"gpt-3.5-turbo": [
{
"model": "gpt-3.5-turbo",
"fallbacks": [{"gpt-3.5-turbo": ["deep-smuggled-model"]}],
}
]
}
],
}
seen: List[str] = []
async def fake_can_key_call_model(model, llm_model_list, valid_token, llm_router):
seen.append(model)
with (
patch(
"litellm.proxy.auth.user_api_key_auth.can_key_call_model",
side_effect=fake_can_key_call_model,
),
patch(
"litellm.proxy.auth.user_api_key_auth.is_valid_fallback_model",
new=AsyncMock(),
),
):
await _enforce_key_and_fallback_model_access(
valid_token=valid_token,
request_data=request_data,
route="/v1/chat/completions",
request=None,
llm_model_list=None,
llm_router=None,
)
assert "deep-smuggled-model" in seen
@pytest.mark.asyncio
async def test_model_less_fallback_dict_is_skipped_never_passed_as_none():
"""A fallback target dict without a ``model`` key is skipped, never passed
as ``None`` into can_key_call_model / is_valid_fallback_model."""
valid_token = _key_with_models(["gpt-3.5-turbo"])
request_data = {
"model": "gpt-3.5-turbo",
"fallbacks": [
{
"gpt-3.5-turbo": [
{"model": "real-fallback"},
{"api_base": "http://attacker"},
"string-fallback",
]
}
],
}
seen: List[str] = []
async def fake_can_key_call_model(model, llm_model_list, valid_token, llm_router):
seen.append(model)
with (
patch(
"litellm.proxy.auth.user_api_key_auth.can_key_call_model",
side_effect=fake_can_key_call_model,
),
patch(
"litellm.proxy.auth.user_api_key_auth.is_valid_fallback_model",
new=AsyncMock(),
),
):
await _enforce_key_and_fallback_model_access(
valid_token=valid_token,
request_data=request_data,
route="/v1/chat/completions",
request=None,
llm_model_list=None,
llm_router=None,
)
assert None not in seen
assert seen == ["gpt-3.5-turbo", "real-fallback", "string-fallback"]
@pytest.mark.asyncio
async def test_router_override_without_fallbacks_does_not_break_auth():
"""``router_settings_override`` set without any fallback fields is a

View file

@ -39,6 +39,46 @@ class TestRejectUrlValuedDestinations:
assert exc_info.value.status_code == 400
assert exc_info.value.detail["param"] == "model"
def test_provider_prefixed_url_rejected(self):
with pytest.raises(HTTPException) as exc_info:
_reject_url_valued_destinations(
{"model": "huggingface/https://attacker.example/v1"}
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["param"] == "model"
def test_comma_batch_smuggled_url_rejected(self):
with pytest.raises(HTTPException) as exc_info:
_reject_url_valued_destinations(
{"model": "gpt-4,huggingface/https://attacker.example/v1"}
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["param"] == "model"
def test_provider_prefixed_uppercase_scheme_url_rejected(self):
with pytest.raises(HTTPException) as exc_info:
_reject_url_valued_destinations(
{"model": "huggingface/HTTPS://evil.example/v1"}
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail["param"] == "model"
def test_provider_prefixed_plain_model_passes(self):
_reject_url_valued_destinations({"model": "huggingface/BAAI/bge-small-en"})
def test_comma_batch_plain_models_pass(self):
_reject_url_valued_destinations({"model": "gpt-4,huggingface/BAAI/bge-small-en"})
def test_provider_prefixed_url_respects_allowlist(self, monkeypatch):
monkeypatch.setattr(
litellm,
"provider_url_destination_allowed_hosts",
["trusted.example"],
)
_reject_url_valued_destinations(
{"model": "huggingface/https://trusted.example/v1"}
)
def test_url_valued_file_id_rejected(self):
with pytest.raises(HTTPException) as exc_info:
_reject_url_valued_destinations(