Skip to content

Commit 1cd15e6

Browse files
committed
fix(auth): preserve repeated resource path separators
1 parent cddd649 commit 1cd15e6

2 files changed

Lines changed: 34 additions & 8 deletions

File tree

src/mcp/shared/auth_utils.py

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

3-
import posixpath
43
import re
54
import time
65
from urllib.parse import urlparse, urlsplit, urlunsplit
@@ -10,18 +9,36 @@
109

1110
def _normalize_resource_path(path: str) -> str:
1211
"""Resolve dot-segments without decoding encoded path separators."""
13-
has_trailing_slash = path.endswith("/")
14-
1512
# RFC 3986 treats percent-encoded unreserved characters as equivalent. Decode
1613
# only encoded dots here: unquoting the whole path would turn %2F into a
1714
# separator and change the resource hierarchy being authorized.
1815
path = re.sub(r"%2e", ".", path, flags=re.IGNORECASE)
19-
path = posixpath.normpath(path)
20-
if path == ".":
21-
path = ""
2216

23-
if has_trailing_slash and not path.endswith("/"):
24-
path += "/"
17+
# Remove RFC 3986 dot-segments without collapsing empty segments. Using
18+
# posixpath.normpath() here would turn /api//v1 into /api/v1 and could
19+
# widen a resource boundary that intentionally contains a repeated slash.
20+
output: list[str] = []
21+
while path:
22+
if path.startswith("/./"):
23+
path = "/" + path[3:]
24+
elif path == "/.":
25+
path = "/"
26+
elif path.startswith("/../"):
27+
path = "/" + path[4:]
28+
if output:
29+
output.pop()
30+
elif path == "/..":
31+
path = "/"
32+
if output:
33+
output.pop()
34+
else:
35+
prefix = "/" if path.startswith("/") else ""
36+
segment = path[len(prefix) :]
37+
segment, separator, remainder = segment.partition("/")
38+
output.append(prefix + segment)
39+
path = ("/" if separator else "") + remainder
40+
41+
path = "".join(output)
2542
return path
2643

2744

tests/shared/test_auth_utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,11 @@ def test_check_resource_allowed_empty_paths():
126126
def test_check_resource_allowed_resolves_dot_segments():
127127
"""Dot-segments should be resolved before checking the resource hierarchy."""
128128
assert check_resource_allowed("https://example.com/api/./v1", "https://example.com/api") is True
129+
assert check_resource_allowed("https://example.com/api/.", "https://example.com/api") is True
129130
assert check_resource_allowed("https://example.com/api/../admin", "https://example.com/api") is False
131+
assert check_resource_allowed("https://example.com/../admin", "https://example.com/api") is False
132+
assert check_resource_allowed("https://example.com/api/..", "https://example.com") is True
133+
assert check_resource_allowed("https://example.com/..", "https://example.com") is True
130134

131135

132136
def test_check_resource_allowed_resolves_percent_encoded_dot_segments():
@@ -138,3 +142,8 @@ def test_check_resource_allowed_resolves_percent_encoded_dot_segments():
138142
def test_check_resource_allowed_preserves_encoded_path_separators():
139143
"""Encoded separators should not be decoded into path hierarchy."""
140144
assert check_resource_allowed("https://example.com/api/a%2Fb", "https://example.com/api/a/b") is False
145+
146+
147+
def test_check_resource_allowed_preserves_repeated_path_separators():
148+
"""Repeated separators should remain significant for resource boundaries."""
149+
assert check_resource_allowed("https://example.com/api/v1", "https://example.com/api//") is False

0 commit comments

Comments
 (0)