From a5834bf6137e7955ad1f538aab154b1092f4e255 Mon Sep 17 00:00:00 2001 From: Yucheng Zhu Date: Fri, 24 Jul 2026 17:54:25 -0700 Subject: [PATCH] fix(credentials): mask all credential values for lower roles on GET /credentials A team-admin or org-admin listing destinations picks them by name and never needs the stored values. The keyed masking heuristic misses fields like otel_headers, exactly where the generic OTLP adapter keeps its auth header, so the non-admin branch now masks every value with a constant instead of relying on key-name matching. Proxy-admin responses are unchanged --- .../proxy/credential_endpoints/endpoints.py | 9 ++- .../credential_endpoints/test_endpoints.py | 55 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py index dd369554544..04698380c22 100644 --- a/litellm/proxy/credential_endpoints/endpoints.py +++ b/litellm/proxy/credential_endpoints/endpoints.py @@ -306,7 +306,8 @@ async def get_credentials( from litellm.proxy.proxy_server import prisma_client try: - if _is_proxy_admin(user_api_key_dict): + is_proxy_admin = _is_proxy_admin(user_api_key_dict) + if is_proxy_admin: visible = list(litellm.credential_list) else: scope = await _caller_admin_scope(user_api_key_dict, prisma_client) @@ -331,7 +332,11 @@ async def get_credentials( masked_credentials = [ { "credential_name": credential.credential_name, - "credential_values": _get_masked_values(credential.credential_values), + "credential_values": ( + _get_masked_values(credential.credential_values) + if is_proxy_admin + else dict.fromkeys(credential.credential_values, "********") + ), "credential_info": credential.credential_info, } for credential in visible diff --git a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py index a62e991391d..0c484c29f1a 100644 --- a/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/credential_endpoints/test_endpoints.py @@ -617,6 +617,46 @@ async def test_get_credentials_shows_only_in_scope_destinations_for_non_admin( assert names == ["poc-langfuse"] +@pytest.mark.asyncio +async def test_get_credentials_masks_all_values_for_non_admin( + monkeypatch, _patch_team_admin_lookup +): + raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret" + monkeypatch.setattr( + litellm, + "credential_list", + [ + CredentialItem( + credential_name="generic-otel", + credential_values={ + "otel_endpoint": "https://collector.example.com/v1/traces", + "otel_headers": raw_headers, + }, + credential_info={ + "credential_type": "logging", + "description": "generic", + "access": {"teams": ["team-existing"]}, + }, + ), + ], + ) + _patch_team_admin_lookup["ids"] = frozenset({"team-existing"}) + + response = await endpoints.get_credentials( + request=MagicMock(), + fastapi_response=MagicMock(), + user_api_key_dict=_team_admin_of(["team-existing"]), + ) + + values = response["credentials"][0]["credential_values"] + assert values == { + "otel_endpoint": "********", + "otel_headers": "********", + } + assert "collector-secret" not in str(response) + assert "api-secret" not in str(response) + + @pytest.mark.asyncio async def test_get_credentials_hides_out_of_scope_destination( monkeypatch, _patch_team_admin_lookup @@ -822,6 +862,7 @@ async def test_patch_credentials_echoes_foreign_team_id_to_legit_team_admin( @pytest.mark.asyncio async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch): + raw_headers = "Authorization=Bearer collector-secret,x-api-key=api-secret" monkeypatch.setattr( litellm, "credential_list", @@ -836,6 +877,14 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch): credential_values={"public_key": "pk-1"}, credential_info=_DEST_WITH_TEAMS, ), + CredentialItem( + credential_name="generic-otel", + credential_values={"otel_headers": raw_headers}, + credential_info={ + "credential_type": "logging", + "description": "generic", + }, + ), ], ) response = await endpoints.get_credentials( @@ -844,7 +893,11 @@ async def test_get_credentials_returns_all_for_proxy_admin(monkeypatch): user_api_key_dict=_admin(), ) names = sorted(c["credential_name"] for c in response["credentials"]) - assert names == ["openai", "poc-langfuse"] + assert names == ["generic-otel", "openai", "poc-langfuse"] + generic = next( + c for c in response["credentials"] if c["credential_name"] == "generic-otel" + ) + assert generic["credential_values"]["otel_headers"] == raw_headers @pytest.mark.asyncio