From daa6ab6cdb1da578ff6af017a2a3766064759c58 Mon Sep 17 00:00:00 2001 From: Dennis Henry Date: Sun, 10 May 2026 17:17:06 -0400 Subject: [PATCH] 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 --- .../proxy_setting_endpoints.py | 38 +++++++++++++++++++ .../test_proxy_setting_endpoints.py | 17 +++++++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 0f88144a2d0..7d56045c833 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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] diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index aa641c05384..87cf9e30c35 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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()