Skip to content

Commit e8b2999

Browse files
committed
fix(auth): canonicalize resource URLs by stripping default ports (RFC 3986)
1 parent 52ad0a8 commit e8b2999

2 files changed

Lines changed: 41 additions & 8 deletions

File tree

src/mcp/shared/auth_utils.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
11
"""Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636)."""
22

33
import time
4-
from urllib.parse import urlparse, urlsplit, urlunsplit
4+
from urllib.parse import SplitResult, urlparse, urlsplit, urlunsplit
55

66
from pydantic import AnyUrl, HttpUrl
77

88

9+
def _canonical_netloc(parsed: SplitResult) -> str:
10+
"""Normalize netloc by lowercasing and stripping explicit default ports (RFC 3986 §6.2.3)."""
11+
scheme = parsed.scheme.lower()
12+
netloc = parsed.netloc.lower()
13+
port = parsed.port
14+
15+
if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
16+
# Strip default port while preserving userinfo and IPv6 brackets
17+
userinfo = ""
18+
if "@" in netloc:
19+
userinfo = netloc.split("@", 1)[0] + "@"
20+
hostname = parsed.hostname.lower() if parsed.hostname else ""
21+
if ":" in hostname: # IPv6 literal
22+
hostname = f"[{hostname}]"
23+
return f"{userinfo}{hostname}"
24+
return netloc
25+
26+
927
def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
1028
"""Convert server URL to canonical resource URL per RFC 8707.
1129
1230
RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component".
13-
Returns absolute URI with lowercase scheme/host for canonical form.
31+
RFC 3986 section 6.2.3 specifies normalization of default ports (80 for http, 443 for https).
32+
Returns absolute URI with lowercase scheme/host and stripped default ports for canonical form.
1433
1534
Args:
1635
url: Server URL to convert
@@ -23,7 +42,8 @@ def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
2342

2443
# Parse the URL and remove fragment, create canonical form
2544
parsed = urlsplit(url_str)
26-
canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment=""))
45+
canonical_netloc = _canonical_netloc(parsed)
46+
canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=canonical_netloc, fragment=""))
2747

2848
return canonical
2949

@@ -43,9 +63,13 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) ->
4363
Returns:
4464
True if the requested resource matches the configured resource
4565
"""
46-
# Parse both URLs
47-
requested = urlparse(requested_resource)
48-
configured = urlparse(configured_resource)
66+
# Canonicalize both resource URLs (RFC 8707 & RFC 3986 default port normalization)
67+
requested_canonical = resource_url_from_server_url(requested_resource)
68+
configured_canonical = resource_url_from_server_url(configured_resource)
69+
70+
# Parse both canonical URLs
71+
requested = urlparse(requested_canonical)
72+
configured = urlparse(configured_canonical)
4973

5074
# Compare scheme, host, and port (origin)
5175
if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower():

tests/shared/test_auth_utils.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ def test_resource_url_from_server_url_preserves_query():
2929

3030

3131
def test_resource_url_from_server_url_preserves_port():
32-
"""Non-default ports should be preserved."""
32+
"""Non-default ports should be preserved while default ports are stripped per RFC 3986 §6.2.3."""
3333
assert resource_url_from_server_url("https://example.com:8443/path") == "https://example.com:8443/path"
3434
assert resource_url_from_server_url("http://example.com:8080/") == "http://example.com:8080/"
35+
assert resource_url_from_server_url("https://example.com:443/path") == "https://example.com/path"
36+
assert resource_url_from_server_url("http://example.com:80/path") == "http://example.com/path"
37+
assert resource_url_from_server_url("http://example.com:80") == "http://example.com"
38+
assert resource_url_from_server_url("https://example.com:443") == "https://example.com"
3539

3640

3741
def test_resource_url_from_server_url_lowercase_scheme_and_host():
@@ -69,9 +73,14 @@ def test_check_resource_allowed_different_domains():
6973

7074

7175
def test_check_resource_allowed_different_ports():
72-
"""Different ports should not match."""
76+
"""Different ports should not match, but explicit default ports are equivalent to omitted ports."""
7377
assert check_resource_allowed("https://example.com:8443/path", "https://example.com/path") is False
7478
assert check_resource_allowed("https://example.com:8080/", "https://example.com:8443/") is False
79+
# Explicit default ports per RFC 3986 §6.2.3
80+
assert check_resource_allowed("https://example.com:443/mcp", "https://example.com/mcp") is True
81+
assert check_resource_allowed("https://example.com/mcp", "https://example.com:443/mcp") is True
82+
assert check_resource_allowed("http://example.com:80/api", "http://example.com/api") is True
83+
assert check_resource_allowed("http://example.com/api", "http://example.com:80/api") is True
7584

7685

7786
def test_check_resource_allowed_hierarchical_matching():

0 commit comments

Comments
 (0)