mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 17:04:30 +00:00
8bbc61e03c
* 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>
226 lines
7.2 KiB
Python
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}"
|
|
)
|