fix(security): redact SSO client secrets in GET /get/sso_settings response

Read-only admins could previously retrieve plaintext okta_client_secret,
google_client_secret, microsoft_client_secret, and generic_client_secret
via GET /get/sso_settings. Now these fields are replaced with "**redacted**"
in the response. PATCH /update/sso_settings preserves the stored secret
when the sentinel value is echoed back unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dennis Henry 2026-05-10 17:17:06 -04:00 committed by Dennis Henry
parent b7f93a8913
commit daa6ab6cdb
No known key found for this signature in database
GPG key ID: 56B1C1BE1C615F34
2 changed files with 51 additions and 4 deletions

View file

@ -633,6 +633,20 @@ async def update_default_team_settings(settings: DefaultTeamSSOParams):
)
# Sentinel returned in place of secret values in GET /get/sso_settings so callers
# can tell a secret is configured without receiving the plaintext value.
_REDACTED_SECRET = "**redacted**"
# SSO fields that contain secrets and must never be returned in plaintext.
_SSO_SECRET_FIELDS = frozenset(
{
"google_client_secret",
"microsoft_client_secret",
"okta_client_secret",
"generic_client_secret",
}
)
@router.get(
"/get/sso_settings",
tags=["SSO Settings"],
@ -743,6 +757,12 @@ async def get_sso_settings():
# Convert to dict for response
sso_dict = sso_config.model_dump()
# Replace secret values with a redacted sentinel so callers can confirm a
# secret is set without receiving the plaintext value.
for _secret_field in _SSO_SECRET_FIELDS:
if sso_dict.get(_secret_field):
sso_dict[_secret_field] = _REDACTED_SECRET
# Add descriptions to the response
result = {
"values": sso_dict,
@ -826,8 +846,26 @@ async def update_sso_settings(sso_config: SSOConfig):
if "general_settings" not in config:
config["general_settings"] = {}
# Load existing SSO record so we can restore secrets that were not changed.
# The GET response returns _REDACTED_SECRET for secret fields; if a caller
# echoes that sentinel back we must not overwrite the real stored value.
existing_sso_record = await prisma_client.db.litellm_ssoconfig.find_unique(
where={"id": "sso_config"}
)
existing_plain: dict = {}
if existing_sso_record and existing_sso_record.sso_settings:
existing_plain = proxy_config._decrypt_and_set_db_env_variables(
environment_variables=dict(existing_sso_record.sso_settings)
)
# Update environment variables in config and in memory
sso_data = sso_config.model_dump()
# Restore original secret values when the sentinel is echoed back.
for _secret_field in _SSO_SECRET_FIELDS:
if sso_data.get(_secret_field) == _REDACTED_SECRET:
sso_data[_secret_field] = existing_plain.get(_secret_field)
for field_name, value in sso_data.items():
if field_name in env_var_mapping:
env_var_name = env_var_mapping[field_name]

View file

@ -362,9 +362,9 @@ class TestProxySettingEndpoints:
# Verify values match our mock config
assert values["google_client_id"] == "test_google_client_id"
assert values["google_client_secret"] == "test_google_client_secret"
assert values["google_client_secret"] == "**redacted**"
assert values["microsoft_client_id"] == "test_microsoft_client_id"
assert values["microsoft_client_secret"] == "test_microsoft_client_secret"
assert values["microsoft_client_secret"] == "**redacted**"
assert values["proxy_base_url"] == "https://example.com"
assert values["user_email"] == "admin@example.com"
@ -393,6 +393,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config.update = AsyncMock()
@ -475,6 +476,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
env_var_entry = MagicMock()
@ -554,6 +556,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
env_var_entry = MagicMock()
env_var_entry.param_value = json.dumps(
@ -624,6 +627,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
env_var_entry = MagicMock()
@ -701,6 +705,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config.update = AsyncMock()
@ -1321,10 +1326,10 @@ class TestProxySettingEndpoints:
assert "values" in data
assert "field_schema" in data
# Verify decrypted values are returned
# Verify decrypted values are returned (secrets are redacted in GET response)
values = data["values"]
assert values["google_client_id"] == "decrypted_google_id"
assert values["google_client_secret"] == "decrypted_google_secret"
assert values["google_client_secret"] == "**redacted**"
assert values["microsoft_client_id"] == "decrypted_microsoft_id"
assert values["proxy_base_url"] == "https://decrypted.example.com"
@ -1345,6 +1350,7 @@ class TestProxySettingEndpoints:
mock_prisma = MagicMock()
upsert_mock = AsyncMock()
mock_prisma.db.litellm_ssoconfig.upsert = upsert_mock
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config.update = AsyncMock()
@ -1424,6 +1430,7 @@ class TestProxySettingEndpoints:
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_ssoconfig = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
env_var_entry = MagicMock()
env_var_entry.param_value = json.dumps(
@ -1477,6 +1484,7 @@ class TestProxySettingEndpoints:
mock_prisma.db = MagicMock()
mock_prisma.db.litellm_ssoconfig = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
env_var_entry = MagicMock()
env_var_entry.param_value = {
@ -1648,6 +1656,7 @@ class TestProxySettingEndpoints:
# Mock the prisma client
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.upsert = AsyncMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config = MagicMock()
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
mock_prisma.db.litellm_config.update = AsyncMock()