mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
686eb3c166
commit
26522c8a79
2 changed files with 129 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue