From ef02cc7efc9dec0158624202c060b23aaca8e203 Mon Sep 17 00:00:00 2001 From: Varshith Puli Date: Tue, 28 Jul 2026 22:09:17 +0530 Subject: [PATCH] Validates registered redirect_uris for DCR are a secure schema with no fragments --- src/mcp/server/auth/handlers/register.py | 15 +++++++++ src/mcp/server/auth/routes.py | 26 +++++++++++++++ tests/server/auth/test_routes.py | 42 +++++++++++++++++++++++- 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 7fb14b2c43..0f9378e458 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -9,6 +9,7 @@ from starlette.responses import Response from mcp.server.auth.errors import stringify_pydantic_error +from mcp.server.auth.routes import validate_redirect_uri from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode from mcp.server.auth.settings import ClientRegistrationOptions @@ -35,6 +36,20 @@ async def handle(self, request: Request) -> Response: body = await request.body() client_metadata = OAuthClientMetadata.model_validate_json(body) + # Validate redirect_uris per RFC 7591 section 2 + if client_metadata.redirect_uris: + for uri in client_metadata.redirect_uris: + try: + validate_redirect_uri(uri) + except ValueError as e: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_redirect_uri", + error_description=str(e), + ), + status_code=400, + ) + # Scope validation is handled below except ValidationError as validation_error: return PydanticJSONResponse( diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index fa88dddcf4..1764e95fed 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -21,6 +21,32 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +def validate_redirect_uri(url: AnyHttpUrl): + """Validate a registered redirect_uri for DCR. + + RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for + redirect_uris, with an HTTP loopback exception for local development. + + Args: + url: The redirect URI to validate. + + Raises: + ValueError: If the redirect URI uses an unsafe scheme or contains + a fragment. + """ + if url.scheme != "https" and url.host not in ( + "localhost", + "127.0.0.1", + "[::1]", + ): + raise ValueError( + "Redirect URI must use HTTPS (or HTTP loopback for local development)" + ) + + if url.fragment is not None: + raise ValueError("Redirect URI must not contain a fragment") + + def validate_issuer_url(url: AnyHttpUrl): """Validate that the issuer URL meets OAuth 2.0 requirements. diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 58685c64c7..5cd3c8748b 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,7 +1,7 @@ import pytest from pydantic import AnyHttpUrl -from mcp.server.auth.routes import build_metadata, validate_issuer_url +from mcp.server.auth.routes import build_metadata, validate_issuer_url, validate_redirect_uri from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions @@ -70,3 +70,43 @@ def test_build_metadata_serves_issuer_without_trailing_slash(): assert served["issuer"] == "https://as.example.com" assert served["authorization_endpoint"] == "https://as.example.com/authorize" assert served["token_endpoint"] == "https://as.example.com/token" + +def test_validate_redirect_uri_https_allowed(): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb')) + + +def test_validate_redirect_uri_http_localhost_allowed(): + validate_redirect_uri(AnyHttpUrl('http://localhost:3000/cb')) + + +def test_validate_redirect_uri_http_127_0_0_1_allowed(): + validate_redirect_uri(AnyHttpUrl('http://127.0.0.1:8080/cb')) + + +def test_validate_redirect_uri_http_ipv6_loopback_allowed(): + validate_redirect_uri(AnyHttpUrl('http://[::1]:9090/cb')) + + +def test_validate_redirect_uri_javascript_scheme_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('javascript:alert(1)')) + + +def test_validate_redirect_uri_file_scheme_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('file:///etc/passwd')) + + +def test_validate_redirect_uri_http_non_loopback_rejected(): + with pytest.raises(ValueError, match='Redirect URI must use HTTPS'): + validate_redirect_uri(AnyHttpUrl('http://evil.com/cb')) + + +def test_validate_redirect_uri_fragment_rejected(): + with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb#frag')) + + +def test_validate_redirect_uri_empty_fragment_rejected(): + with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'): + validate_redirect_uri(AnyHttpUrl('https://example.com/cb#'))