mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
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:
parent
6c0fbe7ef3
commit
19675c0f5c
8 changed files with 112 additions and 73 deletions
|
|
@ -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"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue