fix(mcp): validate client_assertion_signing_alg at the REST boundary, keep stored rows readable (LIT-8429)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-24 09:15:26 +00:00
parent 6c0fbe7ef3
commit 19675c0f5c
8 changed files with 112 additions and 73 deletions

View file

@ -29465,17 +29465,6 @@
"client_assertion_signing_alg": {
"anyOf": [
{
"enum": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
],
"type": "string"
},
{
@ -32177,17 +32166,6 @@
"client_assertion_signing_alg": {
"anyOf": [
{
"enum": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
],
"type": "string"
},
{
@ -35461,17 +35439,6 @@
"client_assertion_signing_alg": {
"anyOf": [
{
"enum": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
],
"type": "string"
},
{
@ -39322,17 +39289,6 @@
"client_assertion_signing_alg": {
"anyOf": [
{
"enum": [
"RS256",
"RS384",
"RS512",
"PS256",
"PS384",
"PS512",
"ES256",
"ES384",
"ES512"
],
"type": "string"
},
{

View file

@ -15,6 +15,8 @@ from pydantic import (
Json,
JsonValue,
PositiveInt,
TypeAdapter,
ValidationError,
field_validator,
model_validator,
)
@ -45,6 +47,10 @@ from litellm.types.mcp import (
MCPTransportType,
)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo
from litellm.types.proxy.auth.jwt_algorithms import (
APPROVED_JWT_ALGORITHMS,
ApprovedJwtAlgorithm,
)
from litellm.types.proxy.carried_budget_state import (
OrgBudgetSnapshot,
TeamBudgetSnapshot,
@ -1502,6 +1508,19 @@ def _reject_unsupported_per_server_oauth_discovery(values: object, require_auth_
raise _per_server_oauth_discovery_error()
_APPROVED_JWT_ALGORITHM_ADAPTER: Final = TypeAdapter(ApprovedJwtAlgorithm)
def _validated_client_assertion_signing_alg(credentials: MCPCredentials | None) -> MCPCredentials | None:
if credentials is None or credentials.get("client_assertion_signing_alg") is None:
return credentials
try:
_APPROVED_JWT_ALGORITHM_ADAPTER.validate_python(credentials["client_assertion_signing_alg"])
except ValidationError as exc:
raise ValueError(f"client_assertion_signing_alg must be one of {', '.join(APPROVED_JWT_ALGORITHMS)}") from exc
return credentials
class NewMCPServerRequest(LiteLLMPydanticObjectBase):
server_id: str | None = None
server_name: str | None = None
@ -1565,6 +1584,11 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
description="Server-managed: set by the endpoint; caller values are overridden.",
)
@field_validator("credentials")
@classmethod
def check_client_assertion_signing_alg(cls, credentials):
return _validated_client_assertion_signing_alg(credentials)
@model_validator(mode="before")
@classmethod
def validate_transport_fields(cls, values):
@ -1664,6 +1688,11 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
timeout: float | None = None
max_concurrent_requests: int | None = None
@field_validator("credentials")
@classmethod
def check_client_assertion_signing_alg(cls, credentials):
return _validated_client_assertion_signing_alg(credentials)
@model_validator(mode="before")
@classmethod
def validate_transport_fields(cls, values):

View file

@ -12,7 +12,6 @@ from pydantic import BaseModel, ConfigDict, Field
from typing_extensions import TypedDict
from litellm.types.llms.base import HiddenParams
from litellm.types.proxy.auth.jwt_algorithms import ApprovedJwtAlgorithm
if TYPE_CHECKING:
import httpx2
@ -252,7 +251,7 @@ class MCPCredentials(TypedDict, total=False):
Key id (kid) advertised in the client_assertion JWT header
"""
client_assertion_signing_alg: ApprovedJwtAlgorithm | None
client_assertion_signing_alg: str | None
"""
Signing algorithm for the client_assertion JWT. Default: RS256
"""

View file

@ -1,10 +1,12 @@
import json
import os
import uuid
from typing import Final
import psycopg
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from integration._support.client import Gateway
from integration._support.client import Gateway, eventually
from integration._support.database import read_rows
from integration._support.mcp import forget_mcp, mcp_peer, register_mcp
@ -85,3 +87,34 @@ def test_approved_client_assertion_signing_alg_round_trips(gateway: Gateway) ->
identity: Final = str(created.json()["server_id"])
scenario.cleanups.callback(forget_mcp, gateway, identity)
assert _stored_client_assertion_signing_alg(identity) == "ES256"
def test_server_row_with_stale_client_assertion_signing_alg_still_loads(gateway: Gateway) -> None:
"""A row persisted before the allowlist (credentials.client_assertion_signing_alg = "HS256")
must keep loading with the RS256 fallback instead of disappearing on upgrade."""
identity: Final = "stalealg" + uuid.uuid4().hex[:8]
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
connection.execute(
'INSERT INTO "LiteLLM_MCPServerTable" (server_id, server_name, url, transport, credentials,'
" created_at, updated_at) VALUES (%s, %s, %s, %s, %s::jsonb, NOW(), NOW())",
(
identity,
"stalealg" + uuid.uuid4().hex[:8],
"https://mcp.integration.invalid",
"http",
json.dumps({"client_assertion_signing_alg": "HS256"}),
),
)
try:
with mcp_peer() as peer, gateway.scenario() as scenario:
register_mcp(scenario, peer, "staletrigger" + uuid.uuid4().hex[:8])
listed: Final = eventually(
lambda: gateway.request("GET", "/v1/mcp/server"),
lambda response: response.status_code == 200
and any(server.get("server_id") == identity for server in response.json()),
seconds=30,
)
assert listed.status_code == 200, listed.text
finally:
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
connection.execute('DELETE FROM "LiteLLM_MCPServerTable" WHERE server_id = %s', (identity,))

View file

@ -1751,3 +1751,20 @@ async def test_unverified_legacy_cache_cannot_bypass_enforcement(monkeypatch):
await mcp_per_user_token_cache.set("alice", "srv", "bob", 60)
assert await module.resolve_user_oauth_access_token("alice", server) is None
assert await mcp_per_user_token_cache.get("alice", "srv") is None
def test_server_table_row_with_stale_client_assertion_signing_alg_still_validates() -> None:
"""Rows written before the approved-algorithm allowlist carry values like HS256 in the
credentials blob; the stored shape stays lenient so reload_servers_from_database still
parses the row and _stored_client_assertion_signing_alg falls back to RS256 instead of
the server silently disappearing on upgrade."""
row: Final = {
"server_id": "srv-stale-alg",
"server_name": "stale_alg_server",
"url": "https://mcp.example.com",
"transport": "http",
"credentials": {"client_assertion_signing_alg": "HS256"},
}
parsed: Final = LiteLLM_MCPServerTable.model_validate(row)
assert parsed.credentials is not None
assert parsed.credentials["client_assertion_signing_alg"] == "HS256"

View file

@ -377,3 +377,33 @@ def test_change_password_request_passwords_hidden_from_repr():
for rendered in (repr(request), str(request)):
assert "hunter2hunter2" not in rendered
assert "NewP@ssw0rd-2026" not in rendered
def test_mcp_server_requests_reject_non_approved_client_assertion_signing_alg() -> None:
"""The REST boundary is strict even though the stored blob stays lenient:
a write carrying HS256/hs256/"" must 422 naming the field."""
from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest
for cls, base in (
(NewMCPServerRequest, {"transport": "http", "url": "https://mcp.example.com"}),
(UpdateMCPServerRequest, {"server_id": "srv-1", "transport": "http", "url": "https://mcp.example.com"}),
):
for alg in ("HS256", "hs256", "", "EdDSA"):
with pytest.raises(ValidationError) as exc:
cls(**base, credentials={"client_assertion_signing_alg": alg})
assert "client_assertion_signing_alg" in str(exc.value), f"{cls.__name__} accepted {alg!r}"
def test_mcp_server_requests_accept_approved_client_assertion_signing_alg() -> None:
from litellm.proxy._types import NewMCPServerRequest, UpdateMCPServerRequest
for alg in ("ES256", "PS384", "RS256", None):
request = NewMCPServerRequest(
transport="http",
url="https://mcp.example.com",
credentials={"client_assertion_signing_alg": alg},
)
assert request.credentials is not None
assert request.credentials["client_assertion_signing_alg"] == alg
update = UpdateMCPServerRequest(server_id="srv-1")
assert update.credentials is None

View file

@ -86,28 +86,3 @@ async def test_the_hook_drops_the_slot_only_once_the_origin_changes() -> None:
await hook(foreign)
assert "esb-oauth" not in foreign.headers
def test_new_mcp_server_request_rejects_non_approved_client_assertion_signing_alg() -> None:
from pydantic import ValidationError
from litellm.proxy._types import NewMCPServerRequest
with pytest.raises(ValidationError) as exc:
NewMCPServerRequest(
transport="http",
url="https://mcp.example.com",
credentials={"client_assertion_signing_alg": "HS256"},
)
assert "client_assertion_signing_alg" in str(exc.value)
def test_new_mcp_server_request_accepts_approved_client_assertion_signing_alg() -> None:
from litellm.proxy._types import NewMCPServerRequest
request = NewMCPServerRequest(
transport="http",
url="https://mcp.example.com",
credentials={"client_assertion_signing_alg": "ES256"},
)
assert request.credentials is not None
assert request.credentials["client_assertion_signing_alg"] == "ES256"

View file

@ -34841,7 +34841,7 @@ export interface components {
/** Aws Session Token */
aws_session_token?: string | null;
/** Client Assertion Signing Alg */
client_assertion_signing_alg?: ("RS256" | "RS384" | "RS512" | "PS256" | "PS384" | "PS512" | "ES256" | "ES384" | "ES512") | null;
client_assertion_signing_alg?: string | null;
/** Client Id */
client_id?: string | null;
/** Client Private Key */