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>
185 lines
6.0 KiB
Python
185 lines
6.0 KiB
Python
"""
|
|
Test /prompts/test endpoint for testing prompts before saving
|
|
"""
|
|
|
|
import pytest
|
|
|
|
|
|
class TestPromptTestEndpoint:
|
|
"""
|
|
Tests the /prompts/test endpoint that allows testing prompts with variables
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_dotprompt_with_variables(self):
|
|
"""
|
|
Test that dotprompt content is parsed and variables are rendered correctly
|
|
"""
|
|
from litellm.integrations.dotprompt.prompt_manager import PromptManager
|
|
|
|
dotprompt_content = """---
|
|
model: gpt-4o
|
|
temperature: 0.7
|
|
max_tokens: 100
|
|
---
|
|
|
|
User: Hello {{name}}, how are you?"""
|
|
|
|
# Parse the dotprompt
|
|
prompt_manager = PromptManager()
|
|
frontmatter, template_content = prompt_manager._parse_frontmatter(
|
|
content=dotprompt_content
|
|
)
|
|
|
|
assert frontmatter["model"] == "gpt-4o"
|
|
assert frontmatter["temperature"] == 0.7
|
|
assert frontmatter["max_tokens"] == 100
|
|
assert "{{name}}" in template_content
|
|
|
|
# Render with variables
|
|
from jinja2 import Environment
|
|
|
|
jinja_env = Environment(
|
|
variable_start_string="{{",
|
|
variable_end_string="}}",
|
|
)
|
|
jinja_template = jinja_env.from_string(template_content)
|
|
rendered = jinja_template.render(name="World")
|
|
|
|
assert "Hello World" in rendered
|
|
assert "{{name}}" not in rendered
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_convert_to_messages_format(self):
|
|
"""
|
|
Test that rendered prompt is converted to OpenAI messages format
|
|
"""
|
|
import re
|
|
|
|
rendered_content = """System: You are a helpful assistant.
|
|
|
|
User: Hello World, how are you?"""
|
|
|
|
messages = []
|
|
role_pattern = r"^(System|User|Assistant|Developer):\s*(.*?)(?=\n(?:System|User|Assistant|Developer):|$)"
|
|
matches = list(
|
|
re.finditer(
|
|
pattern=role_pattern,
|
|
string=rendered_content.strip(),
|
|
flags=re.MULTILINE | re.DOTALL,
|
|
)
|
|
)
|
|
|
|
for match in matches:
|
|
role = match.group(1).lower()
|
|
content = match.group(2).strip()
|
|
|
|
if role == "developer":
|
|
role = "system"
|
|
|
|
if content:
|
|
messages.append({"role": role, "content": content})
|
|
|
|
assert len(messages) == 2
|
|
assert messages[0]["role"] == "system"
|
|
assert "helpful assistant" in messages[0]["content"]
|
|
assert messages[1]["role"] == "user"
|
|
assert "Hello World" in messages[1]["content"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_without_role(self):
|
|
"""
|
|
Test that content without role markers is treated as a user message
|
|
"""
|
|
import re
|
|
|
|
rendered_content = "Just a plain message without any role markers"
|
|
|
|
messages = []
|
|
role_pattern = r"^(System|User|Assistant|Developer):\s*(.*?)(?=\n(?:System|User|Assistant|Developer):|$)"
|
|
matches = list(
|
|
re.finditer(
|
|
pattern=role_pattern,
|
|
string=rendered_content.strip(),
|
|
flags=re.MULTILINE | re.DOTALL,
|
|
)
|
|
)
|
|
|
|
if not matches:
|
|
messages.append({"role": "user", "content": rendered_content.strip()})
|
|
|
|
assert len(messages) == 1
|
|
assert messages[0]["role"] == "user"
|
|
assert messages[0]["content"] == rendered_content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_model_raises_error(self):
|
|
"""
|
|
Test that missing model in frontmatter raises an error
|
|
"""
|
|
from litellm.integrations.dotprompt.prompt_manager import PromptManager
|
|
|
|
dotprompt_content = """---
|
|
temperature: 0.7
|
|
---
|
|
|
|
User: Hello"""
|
|
|
|
prompt_manager = PromptManager()
|
|
frontmatter, _ = prompt_manager._parse_frontmatter(content=dotprompt_content)
|
|
|
|
model = frontmatter.get("model")
|
|
assert model is None
|
|
|
|
def test_ssrf_via_dotprompt_api_base_blocked(self):
|
|
"""
|
|
Regression: api_base in dotprompt YAML frontmatter must be rejected.
|
|
|
|
Without the fix, optional_params (every frontmatter key not in the
|
|
restricted list) was merged into the LLM call data dict and bypassed
|
|
is_request_body_safe, allowing any bearer-key holder to redirect the
|
|
outbound LLM request — and the provider API key — to an
|
|
attacker-controlled host (SSRF / credential exfil).
|
|
|
|
The fix calls is_request_body_safe on the constructed data dict before
|
|
the LLM call. This test verifies:
|
|
1. api_base flows from YAML frontmatter into optional_params (it does).
|
|
2. is_request_body_safe raises ValueError when api_base is present
|
|
without admin opt-in (it does, from _BANNED_REQUEST_BODY_PARAMS).
|
|
"""
|
|
from litellm.integrations.dotprompt.prompt_manager import (
|
|
PromptManager,
|
|
PromptTemplate,
|
|
)
|
|
from litellm.proxy.auth.auth_utils import is_request_body_safe
|
|
|
|
malicious_frontmatter = {
|
|
"model": "gpt-4o",
|
|
"api_base": "https://attacker.example.com",
|
|
"temperature": 0.7,
|
|
}
|
|
|
|
template = PromptTemplate(
|
|
content="User: Hello", metadata=malicious_frontmatter, template_id="test"
|
|
)
|
|
|
|
# api_base must flow into optional_params — that's the attack surface
|
|
assert "api_base" in template.optional_params
|
|
assert template.optional_params["api_base"] == "https://attacker.example.com"
|
|
|
|
# Simulate what test_prompt builds before calling the LLM
|
|
data = {
|
|
"model": template.model,
|
|
"messages": [{"role": "user", "content": "Hello"}],
|
|
}
|
|
data.update(template.optional_params)
|
|
|
|
# is_request_body_safe must reject it without admin opt-in
|
|
with pytest.raises(ValueError, match="api_base"):
|
|
is_request_body_safe(
|
|
request_body=data,
|
|
general_settings={},
|
|
llm_router=None,
|
|
model=data.get("model", ""),
|
|
)
|