fix(proxy): scope the PyNaCl-free encryption scan to MCP fields encrypted at rest

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-24 12:46:13 +00:00
parent 33834c679b
commit f162996af3
4 changed files with 126 additions and 11 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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 ---------------------------

View file

@ -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