Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-unit-proxy-db.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ jobs:
tests/proxy_unit_tests/test_server_root_path.py
tests/proxy_unit_tests/test_proxy_pass_user_config.py
tests/proxy_unit_tests/test_proxy_token_counter.py
tests/proxy_unit_tests/test_multipart_bypass_repro.py
workers: 4
dist: loadscope
timeout: 15
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- AlterTable
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;
ALTER TABLE "LiteLLM_TeamMembership" ADD COLUMN IF NOT EXISTS "total_spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0;

22 changes: 14 additions & 8 deletions litellm/proxy/auth/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,18 +502,24 @@ def get_request_route(request: Request) -> str:
remove base url from path if set e.g. `/genai/chat/completions` -> `/chat/completions
"""
try:
if hasattr(request, "base_url") and request.url.path.startswith(
request.base_url.path
):
# remove base_url from path
return request.url.path[len(request.base_url.path) - 1 :]
else:
return request.url.path
scope = request.scope
if not isinstance(scope, dict):
return str(request.url.path)
raw_path: str = str(scope.get("path", request.url.path))
root_path: str = str(scope.get("app_root_path", scope.get("root_path", "")))
if not isinstance(raw_path, str):
Comment on lines +506 to +510

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Dead type-guard after str() conversion

raw_path is assigned with str(scope.get(...)), so it is always a str. The subsequent if not isinstance(raw_path, str) guard is unreachable and misleads readers into thinking the value could be a non-string at that point.

Suggested change
if not isinstance(scope, dict):
return str(request.url.path)
raw_path: str = str(scope.get("path", request.url.path))
root_path: str = str(scope.get("app_root_path", scope.get("root_path", "")))
if not isinstance(raw_path, str):
raw_path: str = str(scope.get("path", request.url.path))
root_path: str = str(scope.get("app_root_path", scope.get("root_path", "")))
# Only strip root_path when it is a meaningful prefix (not bare "/").

return str(request.url.path)
# Only strip root_path when it is a meaningful prefix (not bare "/").
# Stripping bare "/" would remove the leading slash from every path
# e.g. "/team/new" → "team/new", breaking route matching.
if root_path and root_path != "/" and raw_path.startswith(root_path):
return raw_path[len(root_path) :]
return raw_path
except Exception as e:
verbose_proxy_logger.debug(
f"error on get_request_route: {str(e)}, defaulting to request.url.path={request.url.path}"
)
return request.url.path
return str(request.url.path)


@lru_cache(maxsize=256)
Expand Down
4 changes: 3 additions & 1 deletion litellm/proxy/auth/route_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,9 @@ def is_llm_api_route(route: str) -> bool:
return True

for _llm_passthrough_route in LiteLLMRoutes.mapped_pass_through_routes.value:
if _llm_passthrough_route in route:
if route == _llm_passthrough_route or route.startswith(
_llm_passthrough_route + "/"
):
return True
return False

Expand Down
80 changes: 37 additions & 43 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,41 @@ def _update_internal_user_params(
return non_default_values


def _check_user_update_authz(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
existing_user_row: Optional[BaseModel],
) -> None:
"""Authorization checks for /user/update — raises HTTPException on failure."""
if (
user_request.user_role is not None
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
):
raise HTTPException(
status_code=403, detail="Only proxy admins can modify user roles."
)

if existing_user_row is not None:
typed_row = LiteLLM_UserTable(**existing_user_row.model_dump(exclude_none=True))
if not can_user_call_user_update(
user_api_key_dict=user_api_key_dict, user_info=typed_row
):
raise HTTPException(
status_code=403,
detail={
"error": "User does not have permission to update this user. Only PROXY_ADMIN can update other users."
},
)
elif user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
# Silent-create guard: only PROXY_ADMIN may create via /user/update.
raise HTTPException(
status_code=404,
detail={
"error": "User not found. Only PROXY_ADMIN can create users via /user/update; use /user/new instead."
},
)


async def _update_single_user_helper(
user_request: UpdateUserRequest,
user_api_key_dict: UserAPIKeyAuth,
Expand All @@ -1168,31 +1203,15 @@ async def _update_single_user_helper(
if prisma_client is None:
raise Exception("Not connected to DB!")

# Only proxy admins can modify user_role
if (
user_request.user_role is not None
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value
):
raise HTTPException(
status_code=403,
detail="Only proxy admins can modify user roles.",
)

# Validate user identifier
if not user_request.user_id and not user_request.user_email:
raise ValueError("Either user_id or user_email must be provided")

# Convert to data format expected by update logic
data_json: dict = user_request.model_dump(exclude_unset=True)

# Apply update transformations (reuse existing logic)
non_default_values = _update_internal_user_params(
data_json=data_json, data=user_request
)

_hash_password_in_dict(non_default_values)

# Get existing user data for audit logging and metadata preparation
existing_user_row: Optional[BaseModel] = None
if user_request.user_id:
existing_user_row = await prisma_client.db.litellm_usertable.find_first(
Expand All @@ -1203,37 +1222,12 @@ async def _update_single_user_helper(
where={"user_email": user_request.user_email}
)

_check_user_update_authz(user_request, user_api_key_dict, existing_user_row)

if existing_user_row is not None:
existing_user_row = LiteLLM_UserTable(
**existing_user_row.model_dump(exclude_none=True)
)
if not can_user_call_user_update(
user_api_key_dict=user_api_key_dict,
user_info=existing_user_row,
):
raise HTTPException(
status_code=403,
detail={
"error": "User does not have permission to update this user. Only PROXY_ADMIN can update other users."
},
)
else:
# Silent-create guard: if the target user doesn't exist, the update
# path falls through to an upsert that creates a new user with
# caller-supplied fields (models, metadata, budgets, …). Only
# PROXY_ADMIN is allowed to create users this way; otherwise an org
# admin could spawn arbitrary users attached to nothing by supplying
# a fresh email, bypassing the /user/new org/team-scoping checks.
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
raise HTTPException(
status_code=404,
detail={
"error": (
"User not found. Only PROXY_ADMIN can create users "
"via /user/update; use /user/new instead."
)
},
)

existing_metadata = (
cast(Dict, getattr(existing_user_row, "metadata", {}) or {})
Expand Down
31 changes: 20 additions & 11 deletions litellm/proxy/management_endpoints/key_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2186,23 +2186,32 @@ async def _validate_update_key_data(
# - max_budget / spend: always require the admin check, even for the
# key owner or a team member (matches the existing admin-only
# budget semantics).
is_key_owner = (
user_api_key_dict.user_id is not None
and existing_key_row.user_id == user_api_key_dict.user_id
)
_is_budget_change = (
data.max_budget is not None and data.max_budget != existing_key_row.max_budget
) or (
data.spend is not None
and data.spend != getattr(existing_key_row, "spend", None)
)
is_team_key = existing_key_row.team_id is not None
can_skip_admin_check_for_non_budget = is_key_owner or is_team_key
if (
(not _is_proxy_admin)
and prisma_client is not None
and (_is_budget_change or not can_skip_admin_check_for_non_budget)
):

# Personal-key bypass: the caller both created the key AND still owns it
# (user_id == caller). Checking only created_by would let a demoted admin
# who originally created a key for another user continue editing it without
# admin authorization after the key was reassigned.
caller_is_creator = (
user_api_key_dict.user_id is not None
and getattr(existing_key_row, "created_by", None) == user_api_key_dict.user_id
and getattr(existing_key_row, "user_id", None) == user_api_key_dict.user_id
)
# Team keys: can_team_member_execute_key_management_endpoint (called above)
# already validated team membership + /key/update permission and would have
# raised if the caller lacked it. Reaching this point on a team key for a
# non-budget change means the caller was authorized — skip the redundant
# _check_key_admin_access that would otherwise require team/org admin status.
_key_is_team_key = getattr(existing_key_row, "team_id", None) is not None
can_skip_admin_check = (
caller_is_creator or _key_is_team_key
) and not _is_budget_change
Comment on lines +2200 to +2213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 caller_is_creator silently fails when created_by is NULL on legacy keys

The new bypass condition requires both created_by == caller and user_id == caller. Keys stored with created_by = NULL (possible for keys created before the field was reliably populated, or created via a master-key path where user_api_key_dict.user_id is None) will never satisfy this condition. A user whose user_id is on the key but whose created_by is NULL will now be directed to _check_key_admin_access instead of the former is_key_owner shortcut, potentially losing self-service update capability on their own personal keys. Consider adding a fallback: treat created_by = NULL AND user_id == caller the same as the creator case to preserve backwards compatibility for legacy keys.

if (not _is_proxy_admin) and prisma_client is not None and not can_skip_admin_check:
hashed_key = existing_key_row.token
await _check_key_admin_access(
user_api_key_dict=user_api_key_dict,
Expand Down
10 changes: 10 additions & 0 deletions litellm/proxy/prompts/prompt_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.auth_utils import is_request_body_safe
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.path_utils import safe_filename
from litellm.types.prompts.init_prompts import (
Expand Down Expand Up @@ -1295,6 +1296,13 @@ async def test_prompt(
}
data.update(optional_params)

is_request_body_safe(
request_body=data,
general_settings=general_settings,
llm_router=llm_router,
model=data.get("model", ""),
)

# Use ProxyBaseLLMRequestProcessing to go through all proxy logic
base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
result = await base_llm_response_processor.base_process_llm_request(
Expand Down Expand Up @@ -1323,6 +1331,8 @@ async def test_prompt(

except HTTPException as e:
raise e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
verbose_proxy_logger.exception(f"Error testing prompt: {e}")
raise HTTPException(status_code=500, detail=str(e))
Expand Down
15 changes: 14 additions & 1 deletion litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,10 @@ def generate_feedback_box():
get_team_object,
log_db_metrics,
)
from litellm.proxy.auth.auth_utils import check_response_size_is_safe
from litellm.proxy.auth.auth_utils import (
check_response_size_is_safe,
is_request_body_safe,
)
from litellm.proxy.auth.handle_jwt import JWTHandler
from litellm.proxy.auth.litellm_license import LicenseCheck
from litellm.proxy.auth.model_checks import (
Expand Down Expand Up @@ -10192,6 +10195,16 @@ async def supported_openai_params(model: str):
async def transform_request(request: TransformRequestBody):
from litellm.utils import return_raw_request

try:
is_request_body_safe(
request_body=request.request_body,
general_settings=general_settings,
llm_router=llm_router,
model=request.request_body.get("model", ""),
)
except ValueError as e:
raise HTTPException(status_code=400, detail={"error": str(e)})

return return_raw_request(endpoint=request.call_type, kwargs=request.request_body)


Expand Down
35 changes: 35 additions & 0 deletions litellm/proxy/rag_endpoints/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,41 @@ async def parse_rag_ingest_request(
},
)

# Credential fields must come from server configuration, not user requests.
# 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.
# api_base is also blocked: a user-controlled base URL causes the server
# to send its configured provider credentials to an attacker endpoint.
_BLOCKED_VECTOR_STORE_CREDENTIAL_PARAMS = {
"vertex_credentials",
"vertex_ai_credentials",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
"aws_web_identity_token",
"aws_role_name",
"aws_session_name",
"aws_profile_name",
"aws_sts_endpoint",
"aws_external_id",
"azure_ad_token",
"api_key",
"api_base",
}
vector_store_opts = ingest_options.get("vector_store", {})
if isinstance(vector_store_opts, dict):
for field in _BLOCKED_VECTOR_STORE_CREDENTIAL_PARAMS:
if field in vector_store_opts:
raise HTTPException(
status_code=400,
detail={
"error": f"'{field}' cannot be set in ingest_options.vector_store. "
"Credentials must be configured server-side."
},
)

return ingest_options, file_data, file_url, file_id


Expand Down
75 changes: 75 additions & 0 deletions tests/proxy_unit_tests/test_multipart_bypass_repro.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""
Repro: multipart/form-data delivers litellm_embedding_config as a JSON
string. is_request_body_safe skips the nested banned-param check because
isinstance(nested, dict) is False for a string value.

A banned param (api_base, aws_sts_endpoint, etc.) nested inside the
stringified config is therefore invisible to the bouncer.
"""

import json
import pytest


class TestMultipartNestedBypass:

def test_nested_banned_param_caught_when_dict(self):
"""Baseline: nested api_base inside a dict IS caught."""
from litellm.proxy.auth.auth_utils import is_request_body_safe

request_body = {
"model": "text-embedding-ada-002",
"litellm_embedding_config": {"api_base": "https://attacker.com"},
}

with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=request_body,
general_settings={},
llm_router=None,
model="text-embedding-ada-002",
)

def test_nested_banned_param_blocked_when_json_string(self):
"""
Regression: multipart delivers litellm_embedding_config as a JSON string.
_coerce_metadata_to_dict now parses it before the banned-param check,
so api_base nested inside the stringified config IS caught.
"""
from litellm.proxy.auth.auth_utils import is_request_body_safe

# Exactly what _read_request_body produces for multipart:
# dict(await request.form()) gives string values for non-file fields.
request_body = {
"model": "text-embedding-ada-002",
"litellm_embedding_config": json.dumps(
{"api_base": "https://attacker.com"}
),
}

with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=request_body,
general_settings={},
llm_router=None,
model="text-embedding-ada-002",
)

def test_nested_aws_sts_endpoint_blocked_when_json_string(self):
"""Regression: aws_sts_endpoint nested in JSON-string config is caught."""
from litellm.proxy.auth.auth_utils import is_request_body_safe

request_body = {
"model": "text-embedding-ada-002",
"litellm_embedding_config": json.dumps(
{"aws_sts_endpoint": "https://attacker.com/sts"}
),
}

with pytest.raises(ValueError, match="aws_sts_endpoint"):
is_request_body_safe(
request_body=request_body,
general_settings={},
llm_router=None,
model="text-embedding-ada-002",
)
Loading
Loading