Skip to content

Commit ef02cc7

Browse files
committed
Validates registered redirect_uris for DCR are a secure schema with no fragments
1 parent 6f69a37 commit ef02cc7

3 files changed

Lines changed: 82 additions & 1 deletion

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from starlette.responses import Response
1010

1111
from mcp.server.auth.errors import stringify_pydantic_error
12+
from mcp.server.auth.routes import validate_redirect_uri
1213
from mcp.server.auth.json_response import PydanticJSONResponse
1314
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
1415
from mcp.server.auth.settings import ClientRegistrationOptions
@@ -35,6 +36,20 @@ async def handle(self, request: Request) -> Response:
3536
body = await request.body()
3637
client_metadata = OAuthClientMetadata.model_validate_json(body)
3738

39+
# Validate redirect_uris per RFC 7591 section 2
40+
if client_metadata.redirect_uris:
41+
for uri in client_metadata.redirect_uris:
42+
try:
43+
validate_redirect_uri(uri)
44+
except ValueError as e:
45+
return PydanticJSONResponse(
46+
content=RegistrationErrorResponse(
47+
error="invalid_redirect_uri",
48+
error_description=str(e),
49+
),
50+
status_code=400,
51+
)
52+
3853
# Scope validation is handled below
3954
except ValidationError as validation_error:
4055
return PydanticJSONResponse(

src/mcp/server/auth/routes.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@
2121
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
2222

2323

24+
def validate_redirect_uri(url: AnyHttpUrl):
25+
"""Validate a registered redirect_uri for DCR.
26+
27+
RFC 9700 section 4.1.1 and RFC 7591 section 2 require HTTPS for
28+
redirect_uris, with an HTTP loopback exception for local development.
29+
30+
Args:
31+
url: The redirect URI to validate.
32+
33+
Raises:
34+
ValueError: If the redirect URI uses an unsafe scheme or contains
35+
a fragment.
36+
"""
37+
if url.scheme != "https" and url.host not in (
38+
"localhost",
39+
"127.0.0.1",
40+
"[::1]",
41+
):
42+
raise ValueError(
43+
"Redirect URI must use HTTPS (or HTTP loopback for local development)"
44+
)
45+
46+
if url.fragment is not None:
47+
raise ValueError("Redirect URI must not contain a fragment")
48+
49+
2450
def validate_issuer_url(url: AnyHttpUrl):
2551
"""Validate that the issuer URL meets OAuth 2.0 requirements.
2652

tests/server/auth/test_routes.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22
from pydantic import AnyHttpUrl
33

4-
from mcp.server.auth.routes import build_metadata, validate_issuer_url
4+
from mcp.server.auth.routes import build_metadata, validate_issuer_url, validate_redirect_uri
55
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions
66

77

@@ -70,3 +70,43 @@ def test_build_metadata_serves_issuer_without_trailing_slash():
7070
assert served["issuer"] == "https://as.example.com"
7171
assert served["authorization_endpoint"] == "https://as.example.com/authorize"
7272
assert served["token_endpoint"] == "https://as.example.com/token"
73+
74+
def test_validate_redirect_uri_https_allowed():
75+
validate_redirect_uri(AnyHttpUrl('https://example.com/cb'))
76+
77+
78+
def test_validate_redirect_uri_http_localhost_allowed():
79+
validate_redirect_uri(AnyHttpUrl('http://localhost:3000/cb'))
80+
81+
82+
def test_validate_redirect_uri_http_127_0_0_1_allowed():
83+
validate_redirect_uri(AnyHttpUrl('http://127.0.0.1:8080/cb'))
84+
85+
86+
def test_validate_redirect_uri_http_ipv6_loopback_allowed():
87+
validate_redirect_uri(AnyHttpUrl('http://[::1]:9090/cb'))
88+
89+
90+
def test_validate_redirect_uri_javascript_scheme_rejected():
91+
with pytest.raises(ValueError, match='Redirect URI must use HTTPS'):
92+
validate_redirect_uri(AnyHttpUrl('javascript:alert(1)'))
93+
94+
95+
def test_validate_redirect_uri_file_scheme_rejected():
96+
with pytest.raises(ValueError, match='Redirect URI must use HTTPS'):
97+
validate_redirect_uri(AnyHttpUrl('file:///etc/passwd'))
98+
99+
100+
def test_validate_redirect_uri_http_non_loopback_rejected():
101+
with pytest.raises(ValueError, match='Redirect URI must use HTTPS'):
102+
validate_redirect_uri(AnyHttpUrl('http://evil.com/cb'))
103+
104+
105+
def test_validate_redirect_uri_fragment_rejected():
106+
with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'):
107+
validate_redirect_uri(AnyHttpUrl('https://example.com/cb#frag'))
108+
109+
110+
def test_validate_redirect_uri_empty_fragment_rejected():
111+
with pytest.raises(ValueError, match='Redirect URI must not contain a fragment'):
112+
validate_redirect_uri(AnyHttpUrl('https://example.com/cb#'))

0 commit comments

Comments
 (0)