Skip to content

Commit cddd649

Browse files
committed
fix(auth): normalize resource path dot-segments
1 parent 6e30452 commit cddd649

2 files changed

Lines changed: 42 additions & 2 deletions

File tree

src/mcp/shared/auth_utils.py

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

3+
import posixpath
4+
import re
35
import time
46
from urllib.parse import urlparse, urlsplit, urlunsplit
57

68
from pydantic import AnyUrl, HttpUrl
79

810

11+
def _normalize_resource_path(path: str) -> str:
12+
"""Resolve dot-segments without decoding encoded path separators."""
13+
has_trailing_slash = path.endswith("/")
14+
15+
# RFC 3986 treats percent-encoded unreserved characters as equivalent. Decode
16+
# only encoded dots here: unquoting the whole path would turn %2F into a
17+
# separator and change the resource hierarchy being authorized.
18+
path = re.sub(r"%2e", ".", path, flags=re.IGNORECASE)
19+
path = posixpath.normpath(path)
20+
if path == ".":
21+
path = ""
22+
23+
if has_trailing_slash and not path.endswith("/"):
24+
path += "/"
25+
return path
26+
27+
928
def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str:
1029
"""Convert server URL to canonical resource URL per RFC 8707.
1130
@@ -51,10 +70,14 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) ->
5170
if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower():
5271
return False
5372

73+
# Resolve dot-segments before normalizing trailing slashes so that a
74+
# resource cannot escape its configured path through ../ or its encoded
75+
# equivalent.
76+
requested_path = _normalize_resource_path(requested.path)
77+
configured_path = _normalize_resource_path(configured.path)
78+
5479
# Normalize trailing slashes before comparison so that
5580
# "/foo" and "/foo/" are treated as equivalent.
56-
requested_path = requested.path
57-
configured_path = configured.path
5881
if not requested_path.endswith("/"):
5982
requested_path += "/"
6083
if not configured_path.endswith("/"):

tests/shared/test_auth_utils.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,20 @@ def test_check_resource_allowed_empty_paths():
121121
assert check_resource_allowed("https://example.com", "https://example.com") is True
122122
assert check_resource_allowed("https://example.com/", "https://example.com") is True
123123
assert check_resource_allowed("https://example.com/api", "https://example.com") is True
124+
125+
126+
def test_check_resource_allowed_resolves_dot_segments():
127+
"""Dot-segments should be resolved before checking the resource hierarchy."""
128+
assert check_resource_allowed("https://example.com/api/./v1", "https://example.com/api") is True
129+
assert check_resource_allowed("https://example.com/api/../admin", "https://example.com/api") is False
130+
131+
132+
def test_check_resource_allowed_resolves_percent_encoded_dot_segments():
133+
"""Percent-encoded dot-segments should not bypass the resource boundary."""
134+
assert check_resource_allowed("https://example.com/api/%2e/v1", "https://example.com/api") is True
135+
assert check_resource_allowed("https://example.com/api/%2e%2e/admin", "https://example.com/api") is False
136+
137+
138+
def test_check_resource_allowed_preserves_encoded_path_separators():
139+
"""Encoded separators should not be decoded into path hierarchy."""
140+
assert check_resource_allowed("https://example.com/api/a%2Fb", "https://example.com/api/a/b") is False

0 commit comments

Comments
 (0)