Skip to content

Commit 55187a0

Browse files
Validate DCR redirect_uris with HTTPS-or-loopback policy
Validate DCR redirect_uris with HTTPS-or-loopback policy Fix CI: import order and reject non-loopback redirect test Mark redirect URI scheme requirement as implemented fix(auth): always validate required redirect_uris at registration fix(auth): guard required redirect_uris for pyright and coverage
1 parent cf110e3 commit 55187a0

6 files changed

Lines changed: 94 additions & 43 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mcp.server.auth.json_response import PydanticJSONResponse
1313
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
1414
from mcp.server.auth.settings import ClientRegistrationOptions
15+
from mcp.server.auth.validation import validate_registered_redirect_uri
1516
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
1617

1718
# this alias is a no-op; it's just to separate out the types exposed to the
@@ -90,6 +91,21 @@ async def handle(self, request: Request) -> Response:
9091
status_code=400,
9192
)
9293

94+
redirect_uris = client_metadata.redirect_uris
95+
assert redirect_uris is not None # enforced by OAuthClientMetadata
96+
97+
for redirect_uri in redirect_uris:
98+
try:
99+
validate_registered_redirect_uri(redirect_uri)
100+
except ValueError as error:
101+
return PydanticJSONResponse(
102+
content=RegistrationErrorResponse(
103+
error="invalid_client_metadata",
104+
error_description=str(error),
105+
),
106+
status_code=400,
107+
)
108+
93109
client_id_issued_at = int(time.time())
94110
client_secret_expires_at = (
95111
client_id_issued_at + self.options.client_secret_expiry_seconds

src/mcp/server/auth/routes.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,31 +17,10 @@
1717
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
1818
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
20+
from mcp.server.auth.validation import validate_issuer_url
2021
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
2122
from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata
2223

23-
24-
def validate_issuer_url(url: AnyHttpUrl):
25-
"""Validate that the issuer URL meets OAuth 2.0 requirements.
26-
27-
Args:
28-
url: The issuer URL to validate.
29-
30-
Raises:
31-
ValueError: If the issuer URL is invalid.
32-
"""
33-
34-
# RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing
35-
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
36-
raise ValueError("Issuer URL must be HTTPS")
37-
38-
# No fragments or query parameters allowed
39-
if url.fragment:
40-
raise ValueError("Issuer URL must not have a fragment")
41-
if url.query:
42-
raise ValueError("Issuer URL must not have a query string")
43-
44-
4524
AUTHORIZATION_PATH = "/authorize"
4625
TOKEN_PATH = "/token"
4726
REGISTRATION_PATH = "/register"

src/mcp/server/auth/validation.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from pydantic import AnyHttpUrl, AnyUrl
2+
3+
4+
def validate_issuer_url(url: AnyHttpUrl):
5+
"""Validate that the issuer URL meets OAuth 2.0 requirements.
6+
7+
Args:
8+
url: The issuer URL to validate.
9+
10+
Raises:
11+
ValueError: If the issuer URL is invalid.
12+
"""
13+
14+
# RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing
15+
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
16+
raise ValueError("Issuer URL must be HTTPS")
17+
18+
# No fragments or query parameters allowed
19+
if url.fragment:
20+
raise ValueError("Issuer URL must not have a fragment")
21+
if url.query:
22+
raise ValueError("Issuer URL must not have a query string")
23+
24+
25+
def validate_registered_redirect_uri(url: AnyUrl):
26+
"""Validate that a dynamically registered redirect URI is safe to use.
27+
28+
Mirrors the HTTPS-or-loopback policy used for issuer URLs and rejects
29+
dangerous schemes such as javascript:, data:, and file:.
30+
"""
31+
if url.scheme not in ("https", "http"):
32+
raise ValueError("Redirect URI must use HTTPS or HTTP")
33+
34+
if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"):
35+
raise ValueError("Redirect URI must be HTTPS unless loopback")
36+
37+
if url.fragment:
38+
raise ValueError("Redirect URI must not have a fragment")

tests/interaction/_requirements.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2053,13 +2053,6 @@ def __post_init__(self) -> None:
20532053
"The bundled registration endpoint accepts only redirect URIs that use HTTPS or target a loopback host."
20542054
),
20552055
transports=("streamable-http",),
2056-
divergence=Divergence(
2057-
note=(
2058-
"Not enforced: the registration handler models redirect_uris as AnyUrl with no scheme or "
2059-
"host check, so http://evil.example/callback is accepted and registered. The spec's "
2060-
"localhost-or-HTTPS rule is left to the provider implementation."
2061-
),
2062-
),
20632056
),
20642057
"hosting:auth:as:token-cache-headers": Requirement(
20652058
source="sdk",

tests/interaction/auth/test_as_handlers.py

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -279,22 +279,17 @@ 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+
async def test_a_non_loopback_http_redirect_uri_is_rejected_at_registration(
283283
as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider],
284284
) -> None:
285-
"""A registration carrying a non-HTTPS, non-loopback redirect URI is accepted.
286-
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-
"""
291-
http, provider = as_app
285+
"""Non-loopback HTTP redirect URIs must be rejected during DCR."""
286+
http, _provider = as_app
292287
body = oauth_client_metadata().model_dump(mode="json", exclude_none=True)
293288
body["redirect_uris"] = ["http://evil.example/callback"]
294289

295290
response = await http.post("/register", json=body)
296291

297-
assert response.status_code == 201
298-
info = OAuthClientInformationFull.model_validate_json(response.content)
299-
assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"]
300-
assert info.client_id in provider.clients
292+
assert response.status_code == 400
293+
error = response.json()
294+
assert error["error"] == "invalid_client_metadata"
295+
assert "unless loopback" in error["error_description"]

tests/server/auth/test_routes.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
2-
from pydantic import AnyHttpUrl
2+
from pydantic import AnyHttpUrl, AnyUrl
33

4-
from mcp.server.auth.routes import validate_issuer_url
4+
from mcp.server.auth.validation import validate_issuer_url, validate_registered_redirect_uri
55

66

77
def test_validate_issuer_url_https_allowed():
@@ -45,3 +45,33 @@ def test_validate_issuer_url_fragment_rejected():
4545
def test_validate_issuer_url_query_rejected():
4646
with pytest.raises(ValueError, match="query"):
4747
validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1"))
48+
49+
50+
@pytest.mark.parametrize(
51+
"redirect_uri",
52+
[
53+
"https://example.com/callback",
54+
"http://localhost:8080/callback",
55+
"http://127.0.0.1:8080/callback",
56+
"http://[::1]:8080/callback",
57+
],
58+
)
59+
def test_validate_registered_redirect_uri_allowed(redirect_uri: str):
60+
validate_registered_redirect_uri(AnyUrl(redirect_uri))
61+
62+
63+
@pytest.mark.parametrize(
64+
"redirect_uri,message",
65+
[
66+
("javascript:alert(1)", "HTTPS or HTTP"),
67+
("data:text/html,<script>alert(1)</script>", "HTTPS or HTTP"),
68+
("file:///etc/passwd", "HTTPS or HTTP"),
69+
("vbscript:msgbox(1)", "HTTPS or HTTP"),
70+
("ftp://attacker.example/cb", "HTTPS or HTTP"),
71+
("http://attacker.example/cb", "unless loopback"),
72+
("https://example.com/cb#frag", "fragment"),
73+
],
74+
)
75+
def test_validate_registered_redirect_uri_rejected(redirect_uri: str, message: str):
76+
with pytest.raises(ValueError, match=message):
77+
validate_registered_redirect_uri(AnyUrl(redirect_uri))

0 commit comments

Comments
 (0)