fix: redact nested secrets in config field info for non-admins

/config/field/info returned structured general_settings fields verbatim to
any admin-view caller, so a view-only admin reading database_args received
the nested aws_web_identity_token (a DynamoDB role-assumption credential) in
plaintext. Recurse into dict/list field values and redact secret leaves for
non-PROXY_ADMIN callers, leaving non-secret siblings and full-admin reads
unchanged
This commit is contained in:
yucheng-berriai 2026-06-20 16:51:07 -07:00
parent ad68c6b6a3
commit 686eb3c166
2 changed files with 86 additions and 6 deletions

View file

@ -38,7 +38,7 @@ from typing import (
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 (
@ -15032,6 +15032,24 @@ def _is_secret_general_setting_field(field_name: str) -> bool:
)
def _redact_secret_values_in_obj(value: JsonValue) -> 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"""
if isinstance(value, dict):
return {
key: (
"REDACTED"
if _is_secret_general_setting_field(key)
else _redact_secret_values_in_obj(sub)
)
for key, sub in value.items()
}
if isinstance(value, list):
return [_redact_secret_values_in_obj(item) for item in value]
return value
@router.get(
"/config/field/info",
tags=["config.yaml"],
@ -15087,11 +15105,11 @@ async def get_config_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
and _is_secret_general_setting_field(field_name)
):
field_value = "REDACTED"
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)
return ConfigFieldInfo(field_name=field_name, field_value=field_value)
else:
raise HTTPException(

View file

@ -248,6 +248,68 @@ def test_config_field_info_field_not_in_db(client, auth_as, mock_prisma, monkeyp
assert "not in DB" in response.json().get("detail", {}).get("error", "")
def test_config_field_info_redacts_nested_secret_for_view_only_admin(
client, auth_as, mock_prisma, monkeypatch
):
"""A view-only admin reading a structured field must not receive nested
credentials. database_args carries aws_web_identity_token (a DynamoDB
role-assumption credential); it must come back redacted while non-secret
siblings like region_name stay visible."""
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_args": {
"region_name": "us-east-1",
"user_table_name": "LiteLLM_UserTable",
"aws_web_identity_token": "sk-super-secret-token",
}
}
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_args"}
)
assert response.status_code == 200
value = response.json()["field_value"]
assert value["aws_web_identity_token"] == "REDACTED"
assert value["region_name"] == "us-east-1"
assert value["user_table_name"] == "LiteLLM_UserTable"
def test_config_field_info_full_admin_sees_nested_secret(
client, auth_as, mock_prisma, monkeypatch
):
"""The redaction must not over-redact for a full PROXY_ADMIN, who needs
the real nested value to populate the edit form."""
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_args": {
"region_name": "us-east-1",
"aws_web_identity_token": "sk-super-secret-token",
}
}
table.find_first = AsyncMock(return_value=row)
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
with auth_as(LitellmUserRoles.PROXY_ADMIN):
response = client.get(
"/config/field/info", params={"field_name": "database_args"}
)
assert response.status_code == 200
value = response.json()["field_value"]
assert value["aws_web_identity_token"] == "sk-super-secret-token"
assert value["region_name"] == "us-east-1"
# ---------------------------------------------------------------------------
# GET /config/list
# ---------------------------------------------------------------------------