diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index 1ec0f24ae38..28ba5d32bb7 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -714,20 +714,22 @@ async def _db_find_user_env_var_rows( return await _user_env_var_actions(prisma_client).find_many(where=where) +MCP_CREDENTIAL_SECRET_FIELDS: Final = ( + "auth_value", + "client_id", + "client_secret", + "client_private_key", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", +) + + def decrypt_credentials( credentials: MCPCredentials, ) -> MCPCredentials: """Decrypt all secret fields in an MCPCredentials dict using the global salt key.""" - secret_fields: Final = [ - "auth_value", - "client_id", - "client_secret", - "client_private_key", - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ] - for field in secret_fields: + for field in MCP_CREDENTIAL_SECRET_FIELDS: value = credentials.get(field) if value is not None and isinstance(value, str): credentials[field] = decrypt_value_helper( diff --git a/litellm/proxy/management_endpoints/credential_migration.py b/litellm/proxy/management_endpoints/credential_migration.py index db6e518a663..61dd636e0dd 100644 --- a/litellm/proxy/management_endpoints/credential_migration.py +++ b/litellm/proxy/management_endpoints/credential_migration.py @@ -31,6 +31,7 @@ config rows, and the SSO config table. """ import json +from collections.abc import Iterator from dataclasses import dataclass, field from typing import TYPE_CHECKING, Final, Literal, cast @@ -465,6 +466,7 @@ _COVERED_TABLE_SPECS: Final = [ ("mcp_oauth_client", "litellm_mcpserveroauthclient", ("credentials",), ()), ("sso_identity_assertion", "litellm_ssoidentityassertion", (), ("assertion_b64",)), ] +_MCP_CREDENTIAL_TABLES: Final = frozenset({"litellm_mcpservertable", "litellm_mcpserveroauthclient"}) def _iter_encrypted_strings(obj: object): @@ -485,6 +487,31 @@ def _iter_encrypted_strings(obj: object): stack.extend(cur) +def _mcp_encrypted_leaves(col: str, raw: object) -> Iterator[str]: + """Only the strings an MCP column encrypts at rest: a credentials blob keeps auth_type, scopes and urls in + plaintext and env_vars keeps every name and every per user placeholder, so without PyNaCl those must not be + mistaken for legacy ciphertext and refuse the scan""" + from litellm.proxy._experimental.mcp_server.db import MCP_CREDENTIAL_SECRET_FIELDS, _is_global_env_var_scope + + if col == "credentials": + if not isinstance(raw, dict): + return iter(()) + return (v for k, v in raw.items() if k in MCP_CREDENTIAL_SECRET_FIELDS and isinstance(v, str)) + if not isinstance(raw, list): + return iter(()) + return ( + e["value"] + for e in raw + if isinstance(e, dict) and _is_global_env_var_scope(e.get("scope")) and isinstance(e.get("value"), str) + ) + + +def _encrypted_leaves(db_attr: str, col: str, raw: object) -> Iterator[str]: + if db_attr in _MCP_CREDENTIAL_TABLES and col in ("credentials", "env_vars"): + return _mcp_encrypted_leaves(col, raw) + return _iter_encrypted_strings(raw) + + def _classify_into_report(report: LocationReport, value: str) -> None: """Classify one stored string and bump the matching read-only counter. @@ -548,7 +575,7 @@ async def _scan_one_table( raw = json.loads(raw) except (ValueError, TypeError): pass - for s in _iter_encrypted_strings(raw): + for s in _encrypted_leaves(db_attr, col, raw): _classify_into_report(report, s) for col in scalar_columns: v = getattr(row, col, None) diff --git a/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py b/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py index 24eb3cdab78..b6782ec602d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py +++ b/tests/test_litellm/proxy/management_endpoints/test_credential_migration.py @@ -193,6 +193,49 @@ async def test_check_refuses_without_pynacl_for_every_rotation_rewritten_locatio await cm.check_encryption(prisma_client=client) +@pytest.mark.asyncio +async def test_check_without_pynacl_ignores_plaintext_mcp_metadata_next_to_v3_secrets(salt_key, monkeypatch): + _enable_aes(monkeypatch) + v3_secret: Final = encrypt_value_helper("dcr-secret") + client: Final = MagicMock() + _empty_covered_tables(client) + client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None) + client.db.litellm_config.find_unique = AsyncMock(return_value=None) + client.db.litellm_mcpserveroauthclient.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"}, + ) + ] + ) + client.db.litellm_mcpservertable.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"auth_value": v3_secret, "auth_type": "api_key", "token_url": "https://idp/token"}, + env_vars=[ + {"name": "TOKEN", "scope": "global", "value": v3_secret}, + {"name": "USER_TOKEN", "scope": "user", "value": "{{user.token}}"}, + ], + ) + ] + ) + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + + report: Final = await cm.check_encryption(prisma_client=client) + + by_location: Final = report.as_dict()["locations"] + assert report.residual_legacy == 0, by_location + assert by_location["mcp_oauth_client"]["already_v2"] == 2, by_location + assert by_location["mcp_oauth_client"]["scanned"] == 2, by_location + assert by_location["mcp_server"]["already_v2"] == 2, by_location + assert by_location["mcp_server"]["scanned"] == 2, by_location + + # --------------------------- config-row walker --------------------------- diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 882e38343a6..eadf278a1ca 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -9496,6 +9496,49 @@ async def test_rotate_master_key_refuses_without_pynacl_before_touching_any_row( mock_prisma_client.db.tx.assert_not_called() +async def test_rotate_master_key_preflight_without_pynacl_passes_migrated_oauth_client_with_plaintext_scopes( + monkeypatch, +): + import sys + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy import proxy_server + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _require_legacy_reader_for_stored_values, + ) + + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-salt-rotate") + monkeypatch.setattr(proxy_server, "general_settings", {}) + v3_secret = encrypt_value_helper("dcr-secret") + monkeypatch.setitem(sys.modules, "nacl", None) + monkeypatch.setitem(sys.modules, "nacl.secret", None) + mock_prisma_client = AsyncMock() + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_config.find_unique = AsyncMock(return_value=None) + for table in ( + "litellm_proxymodeltable", + "litellm_credentialstable", + "litellm_mcpservertable", + "litellm_mcpusercredentials", + "litellm_mcpuserenvvars", + "litellm_ssoidentityassertion", + ): + getattr(mock_prisma_client.db, table).find_many = AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_mcpserveroauthclient.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + server_id="s1", + credentials={"client_id": v3_secret, "client_secret": v3_secret, "scopes": ["a"], "auth_type": "oauth2"}, + ) + ] + ) + + await _require_legacy_reader_for_stored_values(mock_prisma_client) + + assert mock_prisma_client.db.litellm_mcpserveroauthclient.find_many.await_count == 1 + + async def test_default_key_generate_params_duration(monkeypatch): """ Test that default_key_generate_params with 'duration' is applied