Skip to content
17 changes: 6 additions & 11 deletions litellm/proxy/management_endpoints/mcp_management_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1016,15 +1016,10 @@ async def fetch_all_mcp_servers(
if is_restricted_virtual_key:
return _sanitize_mcp_server_list_for_virtual_key(redacted_mcp_servers)

# Non-admin authenticated users may see the server inventory but
# not credential-bearing fields like `url` (often contains bearer
# tokens) or headers/env (often contain Authorization).
if not _user_has_admin_view(user_api_key_dict):
return _sanitize_mcp_server_list_for_non_admin(redacted_mcp_servers)

# only a full PROXY_ADMIN sees credential-bearing fields; everyone else
# goes through the non-admin sanitizer
if not _user_is_full_admin(user_api_key_dict):
for server in redacted_mcp_servers:
_redact_global_env_var_values(server)
return _sanitize_mcp_server_list_for_non_admin(redacted_mcp_servers)

return redacted_mcp_servers

Expand Down Expand Up @@ -1415,10 +1410,10 @@ async def fetch_mcp_server(
redacted = _redact_mcp_credentials(mcp_server)
if is_restricted_virtual_key:
return _sanitize_mcp_server_for_virtual_key(redacted)
if not _user_has_admin_view(user_api_key_dict):
return _sanitize_mcp_server_for_non_admin(redacted)
# only a full PROXY_ADMIN sees credential-bearing fields; everyone else
# goes through the non-admin sanitizer
if not _user_is_full_admin(user_api_key_dict):
_redact_global_env_var_values(redacted)
return _sanitize_mcp_server_for_non_admin(redacted)
return redacted

@router.post(
Expand Down
90 changes: 83 additions & 7 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
import anyio
import websockets
import websockets.exceptions
from pydantic import BaseModel, Json
from pydantic import BaseModel, Json, JsonValue

from litellm._uuid import uuid
from litellm.constants import (
Expand Down Expand Up @@ -15181,6 +15181,68 @@ async def update_config_general_settings(
return response


# Secret-bearing general_settings fields the segment masker does not match by
# name: database_url and database_extra_connection_params embed DB credentials,
# pass_through_endpoints carry upstream Authorization headers, and
# alert_to_webhook_url is itself a webhook secret
_EXTRA_SECRET_GENERAL_SETTINGS_FIELDS = frozenset(
{
"database_url",
Comment thread
veria-ai[bot] marked this conversation as resolved.
"database_extra_connection_params",
"pass_through_endpoints",
"alert_to_webhook_url",
}
)


def _is_secret_general_setting_field(field_name: str) -> bool:
return (
field_name in _EXTRA_SECRET_GENERAL_SETTINGS_FIELDS
or SENSITIVE_DATA_MASKER.is_sensitive_key(field_name)
)


# Matches the cap on _redact_sensitive_litellm_params (the closest analog in the
# proxy). Past this depth we fail closed by returning "REDACTED" for the whole
# subtree rather than recursing further — better to over-redact a pathological
# config than to silently return a deeply-nested credential verbatim
_REDACT_SECRET_MAX_DEPTH = 10


def _redact_secret_values_in_obj(value: JsonValue, depth: int = 0) -> JsonValue:
"""Recursively redact secret leaves inside a structured field so a nested
credential (e.g. aws_web_identity_token under database_args) is never
returned to a non-admin, while non-secret siblings stay visible. At
_REDACT_SECRET_MAX_DEPTH the whole subtree is replaced with "REDACTED"
so depth-overrun fails closed."""
if depth >= _REDACT_SECRET_MAX_DEPTH:
return "REDACTED"
if isinstance(value, dict):
return {
key: (
"REDACTED"
if _is_secret_general_setting_field(key)
else _redact_secret_values_in_obj(sub, depth + 1)
)
for key, sub in value.items()
}
if isinstance(value, list):
return [_redact_secret_values_in_obj(item, depth + 1) for item in value]
return value


def _redact_general_setting_value(
field_name: str, value: JsonValue, is_full_admin: bool
) -> JsonValue:
if is_full_admin:
return value
if _is_secret_general_setting_field(field_name):
return "REDACTED"
if isinstance(value, (dict, list)):
return _redact_secret_values_in_obj(value)
return value


@router.get(
"/config/field/info",
tags=["config.yaml"],
Expand Down Expand Up @@ -15233,9 +15295,11 @@ async def get_config_general_settings(
general_settings = dict(db_general_settings.param_value)

if field_name in general_settings:
field_value = general_settings[field_name]
# Redact plugin_key from plugin configs so the shared credential
# is never returned even to admin-viewer callers.
field_value = _redact_general_setting_value(
field_name,
general_settings[field_name],
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN,
)
if field_name == "plugins" and isinstance(field_value, list):
field_value = [
(
Expand Down Expand Up @@ -15291,6 +15355,8 @@ async def get_config_list(
},
)

is_full_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN

## get general settings from db
db_general_settings = await ConfigRepository(prisma_client).table.find_first(
where={"param_name": "general_settings"}
Expand Down Expand Up @@ -15339,7 +15405,11 @@ async def get_config_list(
field_name=sub_field,
field_type=sub_field_type.__name__,
field_description="", # Add custom logic if descriptions are available
field_default_value=general_settings.get(sub_field, None),
field_default_value=_redact_general_setting_value(
sub_field,
general_settings.get(sub_field, None),
is_full_admin,
),
stored_in_db=None,
)
for sub_field, sub_field_type in pydantic_class.__annotations__.items()
Expand Down Expand Up @@ -15369,7 +15439,11 @@ async def get_config_list(
field_name=field_name,
field_type=allowed_args[field_name]["type"],
field_description=field_info.description or "",
field_value=general_settings.get(field_name, None),
field_value=_redact_general_setting_value(
field_name,
general_settings.get(field_name, None),
is_full_admin,
),
stored_in_db=_stored_in_db,
field_default_value=field_info.default,
nested_fields=nested_fields,
Expand All @@ -15393,7 +15467,9 @@ async def get_config_list(
field_name=field_name,
field_type=allowed_args[field_name]["type"],
field_description=field_info.description or "",
field_value=_field_value,
field_value=_redact_general_setting_value(
field_name, _field_value, is_full_admin
),
stored_in_db=_stored_in_db,
field_default_value=field_info.default,
nested_fields=nested_fields,
Expand Down
1 change: 1 addition & 0 deletions tests/code_coverage_tests/recursive_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"_read_image_bytes", # max depth set.
"_get_masked_values", # max depth set (default 20) to prevent infinite recursion while masking nested sensitive config dicts.
"_redact_sensitive_litellm_params", # max depth set (default 10).
"_redact_secret_values_in_obj", # max depth set (default 10, _REDACT_SECRET_MAX_DEPTH); fails closed by returning "REDACTED" at the cap.
"_resolve", # OCI: $ref resolver bounded by `resolving_stack` cycle guard.
"resolve_oci_schema_anyof", # OCI: bounded by JSON-schema tree depth (no cycles possible in well-formed input).
"sanitize_oci_schema", # OCI: bounded by JSON-schema tree depth.
Expand Down
Loading
Loading