Files
litellm/tests/proxy_unit_tests/test_proxy_routes.py
T
Krrish Dholakia 8bbc61e03c fix: harden /key/update authorization checks (#27878)
* fix: patch Host-header auth bypass in get_request_route

Starlette reconstructs request.url from the Host header. A malformed
Host like `localhost/?x=1` causes Starlette to build the full URL as
`http://localhost/?x=1/health`, which url-parses to path="/". Since "/"
is in LiteLLMRoutes.public_routes, all protected routes became reachable
without authentication.

Fix: read scope["path"] (set by uvicorn from the HTTP request line,
not derivable from headers) instead of request.url.path. Sub-path
deployments are handled via scope["app_root_path"] / scope["root_path"],
mirroring Starlette's own base_url construction logic.

Affected variants confirmed fixed:
  Host: localhost/?x=1
  Host: localhost:4000/?x=1
  Host: localhost/#test
  Host: localhost:4000/#test

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* style: reduce comments in route fix

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: block credential fields in RAG ingest vector_store options

Credential fields (vertex_credentials, aws_access_key_id, api_key, etc.)
in ingest_options.vector_store are now rejected at the API boundary with
a 400 error. Credentials must be configured server-side.

Previously any authenticated user could supply a vertex_credentials dict
with type=external_account pointing credential_source.file at an
arbitrary path (e.g. /proc/1/environ) and token_url at an
attacker-controlled server. google-auth's identity_pool.Credentials
refresh() would read the file and POST its contents to the attacker.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: block /key/update self-escalation by assigned users

Non-admin users who were assigned a key (created_by != caller) could
update any non-budget field — models, rpm_limit, guardrails, etc. —
without admin authorization, allowing privilege self-escalation.

Gate: only the key creator (created_by == caller) may edit their own
key without admin check; budget changes always require admin regardless
of creator status. All other callers must pass _check_key_admin_access.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: block user-controlled api_base in RAG ingest vector_store options

A user-supplied api_base in ingest_options.vector_store caused the server
to forward its configured provider credentials (Gemini, OpenAI) to an
attacker-controlled endpoint via SSRF.

Add api_base to the blocked credential params set alongside api_key and
the existing credential fields.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: restrict /utils/transform_request to PROXY_ADMIN and apply body safety check

Any authenticated internal_user could POST arbitrary provider config
(aws_sts_endpoint, api_base, etc.) to /utils/transform_request and have
the server forward its credentials to an attacker-controlled endpoint.

- Gate the endpoint on PROXY_ADMIN role (403 for all other roles)
- Call is_request_body_safe() to reject banned params even for admins
- Convert ValueError from safety check to HTTP 400

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: apply banned-param check to /utils/transform_request

Without is_request_body_safe(), any authenticated user could pass
aws_sts_endpoint, api_base, or aws_web_identity_token to
/utils/transform_request and have the server forward its configured
provider credentials to an attacker-controlled endpoint during SDK
credential resolution.

Applies the same banned-param blocklist already used by LLM endpoints.
Endpoint remains accessible to all authenticated users.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: block SSRF via api_base in /prompts/test dotprompt YAML frontmatter

Any frontmatter key not in ["model","input","output"] flowed into
optional_params and was merged into the LLM call data dict, bypassing
is_request_body_safe. An attacker with any bearer key could set
api_base in YAML to redirect the outbound LLM request — including the
provider API key — to an attacker-controlled host.

Fix: call is_request_body_safe on the constructed data dict after
optional_params are merged, before invoking ProxyBaseLLMRequestProcessing.
ValueError from the banned-param check is surfaced as HTTP 400.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Update litellm/proxy/rag_endpoints/endpoints.py

Co-authored-by: veria-ai[bot] <224490171+veria-ai[bot]@users.noreply.github.com>

* fix: coerce nested config strings before banned-param check

_NESTED_CONFIG_KEYS descent used isinstance(nested, dict) which silently
skipped litellm_embedding_config when delivered as a JSON string via
multipart/form-data. Banned params (api_base, aws_sts_endpoint, etc.)
nested inside the stringified value were invisible to is_request_body_safe.

_NESTED_METADATA_KEYS already used _coerce_metadata_to_dict which parses
JSON strings before checking. Apply the same coercion to _NESTED_CONFIG_KEYS.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: replace substring match with prefix match in is_llm_api_route

mapped_pass_through_routes used `_llm_passthrough_route in route` (substring)
so any admin-only path whose URL contained a provider name (openai, anthropic,
azure, bedrock, etc.) was misclassified as an LLM API route and bypassed the
admin gate in non_proxy_admin_allowed_routes_check.

Confirmed live: non-admin key could GET /credentials/by_name/openai (read
masked provider API key) and DELETE /credentials/openai (delete credential).

Fix: use exact match or startswith(prefix + "/") — the same pattern used
everywhere else in RouteChecks — so only routes that actually start with a
passthrough prefix are allowed through.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: stabilize PR #27878 test failures

- key_management_endpoints: extend can_skip_admin_check to team keys so
  team members with /key/update permission can update non-budget fields.
  can_team_member_execute_key_management_endpoint already validates team
  membership + permission and raises if unauthorized; reaching the admin
  check on a team key means the caller was authorized.

- test: set created_by on mock key in
  test_update_key_non_budget_fields_allowed_for_internal_user so
  caller_is_creator resolves correctly (MagicMock default ≠ user_id).

- auth_utils.get_request_route: guard against non-dict request.scope
  (e.g. MagicMock in unit tests) to prevent a MagicMock leaking into
  UserAPIKeyAuth.request_route and failing Pydantic validation.

- ci: assign test_multipart_bypass_repro.py to the proxy-runtime shard
  in test-unit-proxy-db.yml to satisfy the shard-coverage check.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix(lint): add explicit str() cast in get_request_route for MyPy

scope.get() returns Any|None which MyPy cannot coerce to str implicitly.
Wrap both scope.get() calls in str() to satisfy the type checker.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: guard bare-/ root_path strip + make total_spend migration idempotent

auth_utils.get_request_route: when Starlette sets scope["app_root_path"]
to "/" (e.g. behind some middleware), the old stripping logic would
remove the leading slash from every path ("/team/new" → "team/new"),
breaking route matching and causing auth to misclassify protected routes.
Skip stripping when root_path is bare "/".

migration: add IF NOT EXISTS to total_spend ALTER TABLE so the migration
is safe to replay when a prior partial run already created the column.
Without this guard, prisma migrate deploy fails on CI DBs that were
partially migrated, causing all subsequent DB operations (including
/team/new) to 500.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: require creator still owns key for personal-key bypass in /key/update

caller_is_creator now requires both created_by == caller AND user_id ==
caller. Previously checking only created_by let a demoted admin who
originally created a key for another user continue editing non-budget
fields on it after reassignment, bypassing _check_key_admin_access.

Adds regression test: creator whose key was reassigned is blocked (403).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* fix: extract auth checks to fix PLR0915 + broaden max_budget assertion

internal_user_endpoints._update_single_user_helper exceeded 50 statements
(PLR0915). Extract authorization checks into _check_user_update_authz helper
to bring statement count under the limit.

test_validate_max_budget: assert "negative" (substring of both the local
"cannot be negative" and the CI "non-negative finite number" messages) so
the test is stable regardless of which exact wording the function uses.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: veria-ai[bot] <224490171+veria-ai[bot]@users.noreply.github.com>
2026-05-14 04:16:04 +00:00

226 lines
7.2 KiB
Python

import os
import sys
from dotenv import load_dotenv
load_dotenv()
import io
import os
# this file is to test litellm/proxy
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
import asyncio
import logging
import pytest
from fastapi import Request
from starlette.datastructures import URL, Headers, QueryParams
import litellm
from litellm.proxy._types import LiteLLMRoutes
from litellm.proxy.auth.auth_utils import get_request_route
from litellm.proxy.auth.route_checks import RouteChecks
from litellm.proxy.proxy_server import app
# Configure logging
logging.basicConfig(
level=logging.DEBUG, # Set the desired logging level
format="%(asctime)s - %(levelname)s - %(message)s",
)
def test_routes_on_litellm_proxy():
"""
Goal of this test: Test that we have all the critical OpenAI Routes on the Proxy server Fast API router
this prevents accidentelly deleting /threads, or /batches etc
"""
# Force-load lazy features so the test sees the full route set. Continue
# on per-feature import failure — the assertion below still catches
# missing-route regressions.
import importlib
from litellm.proxy._lazy_features import LAZY_FEATURES
registered_paths = [getattr(r, "path", "") for r in app.routes]
for feat in LAZY_FEATURES:
if any(rp.startswith(p) for p in feat.path_prefixes for rp in registered_paths):
continue
try:
module = importlib.import_module(feat.module_path)
feat.register_fn(app, module)
except Exception as exc:
print(f"warning: failed to force-load {feat.name}: {exc}")
_all_routes = []
for route in app.routes:
_path_as_str = str(route.path)
if ":path" in _path_as_str:
# remove the :path
_path_as_str = _path_as_str.replace(":path", "")
_all_routes.append(_path_as_str)
print("ALL ROUTES on LiteLLM Proxy:", _all_routes)
print("\n\n")
print("ALL OPENAI ROUTES:", LiteLLMRoutes.openai_routes.value)
for route in LiteLLMRoutes.openai_routes.value:
# realtime routes - /realtime?model=gpt-4o
if "realtime" in route:
assert "/realtime" in _all_routes
# wildcard patterns like /containers/* - check that base path exists
elif RouteChecks._is_wildcard_pattern(pattern=route):
# For wildcard patterns, check that the base path (without * and trailing /) exists
base_path = route[:-1].rstrip(
"/"
) # Remove the trailing * and any trailing /
# Check if base path exists (e.g., /containers or /v1/containers)
assert (
base_path in _all_routes
), f"Wildcard pattern {route} requires base path {base_path} to exist"
else:
assert route in _all_routes
@pytest.mark.parametrize(
"route,expected",
[
# Test exact matches
("/chat/completions", True),
("/v1/chat/completions", True),
("/embeddings", True),
("/v1/models", True),
("/utils/token_counter", True),
# Test routes with placeholders
("/engines/gpt-4/chat/completions", True),
("/openai/deployments/gpt-3.5-turbo/chat/completions", True),
("/threads/thread_49EIN5QF32s4mH20M7GFKdlZ", True),
("/v1/threads/thread_49EIN5QF32s4mH20M7GFKdlZ", True),
("/threads/thread_49EIN5QF32s4mH20M7GFKdlZ/messages", True),
("/v1/threads/thread_49EIN5QF32s4mH20M7GFKdlZ/runs", True),
("/v1/batches/123456", True),
# Test non-OpenAI routes
("/some/random/route", False),
("/v2/chat/completions", False),
("/threads/invalid/format", False),
("/v1/non_existent_endpoint", False),
# Bedrock Pass Through Routes
("/bedrock/model/cohere.command-r-v1:0/converse", True),
("/vertex-ai/model/text-embedding-004/embeddings", True),
# LiteLLM native RAG routes
("/rag/ingest", True),
("/v1/rag/ingest", True),
("/rag/query", True),
("/v1/rag/query", True),
],
)
def test_is_llm_api_route(route: str, expected: bool):
assert RouteChecks.is_llm_api_route(route) == expected
# Test-case for routes that are similar but should return False
@pytest.mark.parametrize(
"route",
[
"/v1/threads/thread_id/invalid",
"/threads/thread_id/invalid",
"/v1/batches/123/invalid",
"/engines/model/invalid/completions",
],
)
def test_is_llm_api_route_similar_but_false(route: str):
assert RouteChecks.is_llm_api_route(route) is False
def test_anthropic_api_routes():
# allow non proxy admins to call anthropic api routes
assert RouteChecks.is_llm_api_route(route="/v1/messages") is True
def create_request(path: str, base_url: str = "http://testserver") -> Request:
return Request(
{
"type": "http",
"method": "GET",
"scheme": "http",
"server": ("testserver", 80),
"path": path,
"query_string": b"",
"headers": Headers().raw,
"client": ("testclient", 50000),
"root_path": URL(base_url).path,
}
)
def test_get_request_route_with_base_url():
request = create_request(
path="/genai/chat/completions", base_url="http://testserver/genai"
)
result = get_request_route(request)
assert result == "/chat/completions"
def test_get_request_route_without_base_url():
request = create_request("/chat/completions")
result = get_request_route(request)
assert result == "/chat/completions"
def test_get_request_route_with_nested_path():
request = create_request(path="/embeddings", base_url="http://testserver/ishaan")
result = get_request_route(request)
assert result == "/embeddings"
def test_get_request_route_with_query_params():
request = create_request(path="/genai/test", base_url="http://testserver/genai")
request.scope["query_string"] = b"param=value"
result = get_request_route(request)
assert result == "/test"
def test_get_request_route_with_base_url_not_at_start():
request = create_request("/api/genai/test")
result = get_request_route(request)
assert result == "/api/genai/test"
def _create_request_with_host_header(path: str, host_header: str) -> Request:
return Request(
{
"type": "http",
"method": "GET",
"scheme": "http",
"server": ("localhost", 4000),
"path": path,
"query_string": b"",
"headers": [(b"host", host_header.encode())],
"client": ("127.0.0.1", 50000),
"root_path": "",
}
)
@pytest.mark.parametrize(
"host_header",
[
"localhost/?x=1",
"localhost:4000/?x=1",
"localhost/#test",
"localhost:4000/#test",
],
)
def test_get_request_route_not_bypassed_by_malformed_host(host_header: str):
for protected_path in ["/health", "/user/new", "/key/generate", "/get/internal_user_settings"]:
request = _create_request_with_host_header(path=protected_path, host_header=host_header)
result = get_request_route(request)
assert result == protected_path, (
f"Host: {host_header!r} caused route {protected_path!r} to resolve as {result!r}"
)