fix: redact secret config values in /config/list for non-admins

/config/list shared the same _user_has_admin_view gate as /config/field/info
but returned each field value unredacted, so a view-only admin reading the
list received pass_through_endpoints upstream Authorization headers verbatim.
Route every general_settings value through a shared role-aware redactor
(extracted from /config/field/info) covering the top-level and nested field
paths, so non-PROXY_ADMIN callers get secret-bearing fields redacted while
full-admin reads stay unchanged
This commit is contained in:
yucheng-berriai 2026-06-20 17:14:27 -07:00
parent 686eb3c166
commit 26522c8a79
2 changed files with 129 additions and 13 deletions

View file

@ -15050,6 +15050,18 @@ def _redact_secret_values_in_obj(value: JsonValue) -> JsonValue:
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"],
@ -15102,14 +15114,11 @@ async def get_config_general_settings(
general_settings = dict(db_general_settings.param_value)
if field_name in general_settings:
# only a full PROXY_ADMIN sees raw secret-bearing fields; others
# get them redacted
field_value = general_settings[field_name]
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
if _is_secret_general_setting_field(field_name):
field_value = "REDACTED"
elif isinstance(field_value, (dict, list)):
field_value = _redact_secret_values_in_obj(field_value)
field_value = _redact_general_setting_value(
field_name,
general_settings[field_name],
user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN,
)
return ConfigFieldInfo(field_name=field_name, field_value=field_value)
else:
raise HTTPException(
@ -15156,6 +15165,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"}
@ -15204,7 +15215,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()
@ -15234,7 +15249,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,
@ -15258,7 +15277,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,

View file

@ -15,8 +15,6 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from .conftest import VOLATILE_KEYS, normalize
@ -310,6 +308,103 @@ def test_config_field_info_full_admin_sees_nested_secret(
assert value["region_name"] == "us-east-1"
def test_config_field_info_redacts_top_level_scalar_for_view_only(
client, auth_as, mock_prisma, monkeypatch
):
"""The top-level scalar branch must also redact for a view-only admin.
database_url carries DB credentials and is not caught by the name masker,
so it is in the explicit secret set."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
row = MagicMock()
row.param_value = {"database_url": "postgresql://admin:p4ss@db:5432/litellm"}
table.find_first = AsyncMock(return_value=row)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
response = client.get(
"/config/field/info", params={"field_name": "database_url"}
)
assert response.status_code == 200
assert response.json()["field_value"] == "REDACTED"
def test_redact_general_setting_value_recurses_list_of_dicts():
"""The list branch of the recursor redacts secret leaves inside each dict
while non-secret keys survive, and a full admin gets the value untouched."""
from litellm.proxy import proxy_server as ps
value = [
{"path": "/foo", "headers": {"Authorization": "Bearer sk-x"}},
{"path": "/bar", "client_secret": "sk-y"},
]
redacted = ps._redact_general_setting_value(
"some_list_field", value, is_full_admin=False
)
assert redacted[0]["headers"]["Authorization"] == "REDACTED"
assert redacted[0]["path"] == "/foo"
assert redacted[1]["client_secret"] == "REDACTED"
assert redacted[1]["path"] == "/bar"
assert (
ps._redact_general_setting_value("some_list_field", value, is_full_admin=True)
== value
)
def test_config_list_redacts_pass_through_secret_for_view_only(
client, auth_as, mock_prisma, monkeypatch
):
"""/config/list must not leak pass_through_endpoints upstream credentials
to a view-only admin. pass_through_endpoints is a known secret-bearing
field, so a non-admin gets it redacted; a full admin still sees it."""
from litellm.proxy import proxy_server as ps
from litellm.proxy._types import LitellmUserRoles
table = _install_litellm_config(mock_prisma)
row = MagicMock()
row.param_value = {"max_parallel_requests": 3}
table.find_first = AsyncMock(return_value=row)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
monkeypatch.setattr(
ps,
"general_settings",
{
"pass_through_endpoints": [
{
"path": "/foo",
"target": "https://upstream.example.com",
"headers": {"Authorization": "Bearer sk-UPSTREAM-SECRET"},
}
]
},
)
def _pass_through_value(body):
return next(
entry["field_value"]
for entry in body
if entry["field_name"] == "pass_through_endpoints"
)
with auth_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY):
view_resp = client.get(
"/config/list", params={"config_type": "general_settings"}
)
assert view_resp.status_code == 200
assert "sk-UPSTREAM-SECRET" not in view_resp.text
assert _pass_through_value(view_resp.json()) == "REDACTED"
with auth_as(LitellmUserRoles.PROXY_ADMIN):
admin_resp = client.get(
"/config/list", params={"config_type": "general_settings"}
)
assert admin_resp.status_code == 200
admin_value = _pass_through_value(admin_resp.json())
assert admin_value[0]["headers"]["Authorization"] == "Bearer sk-UPSTREAM-SECRET"
# ---------------------------------------------------------------------------
# GET /config/list
# ---------------------------------------------------------------------------