Skip to content

Commit f616d5e

Browse files
author
冯基魁
committed
fix: validate DCR redirect URIs
1 parent a527142 commit f616d5e

3 files changed

Lines changed: 64 additions & 16 deletions

File tree

src/mcp/server/auth/handlers/register.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from typing import Any
55
from uuid import uuid4
66

7-
from pydantic import BaseModel, ValidationError
7+
from pydantic import AnyUrl, BaseModel, ValidationError
88
from starlette.requests import Request
99
from starlette.responses import Response
1010

@@ -18,12 +18,29 @@
1818
# provider from what we use in the HTTP handler
1919
RegistrationRequest = OAuthClientMetadata
2020

21+
LOOPBACK_REDIRECT_HOSTS = ("localhost", "127.0.0.1", "[::1]")
22+
2123

2224
class RegistrationErrorResponse(BaseModel):
2325
error: RegistrationErrorCode
2426
error_description: str | None
2527

2628

29+
def validate_redirect_uri(uri: AnyUrl) -> None:
30+
"""Validate a dynamic client registration redirect URI."""
31+
if uri.scheme != "https" and uri.host not in LOOPBACK_REDIRECT_HOSTS:
32+
raise RegistrationError(
33+
error="invalid_redirect_uri",
34+
error_description="redirect_uris must use HTTPS or target a loopback host",
35+
)
36+
37+
if uri.fragment:
38+
raise RegistrationError(
39+
error="invalid_redirect_uri",
40+
error_description="redirect_uris must not include a fragment",
41+
)
42+
43+
2744
@dataclass
2845
class RegistrationHandler:
2946
provider: OAuthAuthorizationServerProvider[Any, Any, Any]
@@ -35,6 +52,9 @@ async def handle(self, request: Request) -> Response:
3552
body = await request.body()
3653
client_metadata = OAuthClientMetadata.model_validate_json(body)
3754

55+
for redirect_uri in client_metadata.redirect_uris or ():
56+
validate_redirect_uri(redirect_uri)
57+
3858
# Scope validation is handled below
3959
except ValidationError as validation_error:
4060
return PydanticJSONResponse(
@@ -44,6 +64,14 @@ async def handle(self, request: Request) -> Response:
4464
),
4565
status_code=400,
4666
)
67+
except RegistrationError as registration_error:
68+
return PydanticJSONResponse(
69+
content=RegistrationErrorResponse(
70+
error=registration_error.error,
71+
error_description=registration_error.error_description,
72+
),
73+
status_code=400,
74+
)
4775

4876
client_id = str(uuid4())
4977

tests/interaction/_requirements.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2713,13 +2713,6 @@ def __post_init__(self) -> None:
27132713
),
27142714
transports=("streamable-http",),
27152715
note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.",
2716-
divergence=Divergence(
2717-
note=(
2718-
"Not enforced: the registration handler models redirect_uris as AnyUrl with no scheme or "
2719-
"host check, so http://evil.example/callback is accepted and registered. The spec's "
2720-
"localhost-or-HTTPS rule is left to the provider implementation."
2721-
),
2722-
),
27232716
),
27242717
"hosting:auth:as:token-cache-headers": Requirement(
27252718
source="sdk",

tests/interaction/auth/test_as_handlers.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -279,22 +279,49 @@ async def test_authorize_with_an_unregistered_redirect_uri_is_rejected_directly(
279279

280280

281281
@requirement("hosting:auth:as:redirect-uri-scheme")
282-
async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration(
282+
@pytest.mark.parametrize(
283+
"redirect_uri",
284+
[
285+
"http://evil.example/callback",
286+
"javascript:alert(1)",
287+
"data:text/html,<script>alert(1)</script>",
288+
"file:///etc/passwd",
289+
"https://client.example.com/callback#fragment",
290+
],
291+
)
292+
async def test_registration_rejects_redirect_uris_without_https_or_loopback(
283293
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
294+
redirect_uri: str,
284295
) -> None:
285-
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
296+
"""The registration endpoint rejects redirect URIs without HTTPS or a loopback host."""
297+
http, provider = as_app
298+
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
299+
body["redirect_uris"] = [redirect_uri]
286300

287-
The spec requires every redirect URI to be either HTTPS or a loopback host; the bundled
288-
registration handler does not enforce this and registers `http://evil.example/callback`
289-
successfully. See the divergence on the requirement.
290-
"""
301+
response = await http.post("/register", json=body)
302+
303+
assert response.status_code == 400
304+
assert response.json()["error"] == "invalid_redirect_uri"
305+
assert provider.clients == {}
306+
307+
308+
@requirement("hosting:auth:as:redirect-uri-scheme")
309+
async def test_registration_allows_https_and_loopback_redirect_uris(
310+
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
311+
) -> None:
312+
"""HTTPS redirect URIs and loopback HTTP redirect URIs remain valid."""
291313
http, provider = as_app
292314
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
293-
body["redirect_uris"] = ["http://evil.example/callback"]
315+
body["redirect_uris"] = [
316+
"https://client.example.com/callback?next=%2Fhome",
317+
"http://localhost:3000/callback",
318+
"http://127.0.0.1:3000/callback",
319+
"http://[::1]:3000/callback",
320+
]
294321

295322
response = await http.post("/register", json=body)
296323

297324
assert response.status_code == 201
298325
info = OAuthClientInformationFull.model_validate_json(response.content)
299-
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
326+
assert [str(u) for u in (info.redirect_uris or [])] == body["redirect_uris"]
300327
assert info.client_id in provider.clients

0 commit comments

Comments
 (0)