-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Resolve dot-segments when deriving and matching OAuth resource URLs #3343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,41 @@ | ||
| """Utilities for OAuth 2.0 Resource Indicators (RFC 8707) and PKCE (RFC 7636).""" | ||
|
|
||
| import time | ||
| from urllib.parse import urlparse, urlsplit, urlunsplit | ||
| from urllib.parse import urlsplit, urlunsplit | ||
|
|
||
| from pydantic import AnyUrl, HttpUrl | ||
|
|
||
| # WHATWG URL treats these percent-encoded spellings as dot-segments too. | ||
| _SINGLE_DOT_SEGMENTS = {".", "%2e"} | ||
| _DOUBLE_DOT_SEGMENTS = {"..", ".%2e", "%2e.", "%2e%2e"} | ||
|
|
||
|
|
||
| def _remove_dot_segments(path: str) -> str: | ||
| """Resolve "." and ".." segments in a URL path (RFC 3986 section 5.2.4).""" | ||
| segments = path.split("/") | ||
| output: list[str] = [] | ||
| for index, segment in enumerate(segments): | ||
| is_last = index == len(segments) - 1 | ||
| kind = segment.lower() | ||
| if kind in _DOUBLE_DOT_SEGMENTS: | ||
| if len(output) > 1: | ||
| output.pop() | ||
| if is_last: | ||
| output.append("") | ||
| elif kind in _SINGLE_DOT_SEGMENTS: | ||
| if is_last: | ||
| output.append("") | ||
| else: | ||
| output.append(segment) | ||
| return "/".join(output) | ||
|
|
||
|
|
||
| def resource_url_from_server_url(url: str | HttpUrl | AnyUrl) -> str: | ||
| """Convert server URL to canonical resource URL per RFC 8707. | ||
|
|
||
| RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". | ||
| Returns absolute URI with lowercase scheme/host for canonical form. | ||
| Returns absolute URI with lowercase scheme/host and dot-segments resolved, so the | ||
| resource identifies the same location an HTTP client would actually request. | ||
|
|
||
| Args: | ||
| url: Server URL to convert | ||
|
|
@@ -23,7 +48,14 @@ | |
|
|
||
| # Parse the URL and remove fragment, create canonical form | ||
| parsed = urlsplit(url_str) | ||
| canonical = urlunsplit(parsed._replace(scheme=parsed.scheme.lower(), netloc=parsed.netloc.lower(), fragment="")) | ||
| canonical = urlunsplit( | ||
| parsed._replace( | ||
| scheme=parsed.scheme.lower(), | ||
| netloc=parsed.netloc.lower(), | ||
| path=_remove_dot_segments(parsed.path), | ||
|
Check notice on line 55 in src/mcp/shared/auth_utils.py
|
||
| fragment="", | ||
| ) | ||
| ) | ||
|
|
||
| return canonical | ||
|
|
||
|
|
@@ -34,7 +66,8 @@ | |
| A requested resource matches if it has the same scheme, domain, port, | ||
| and its path starts with the configured resource's path. This allows | ||
| hierarchical matching where a token for a parent resource can be used | ||
| for child resources. | ||
| for child resources. Dot-segments in either path are resolved before | ||
| comparing. | ||
|
|
||
| Args: | ||
| requested_resource: The resource URL being requested | ||
|
|
@@ -44,17 +77,17 @@ | |
| True if the requested resource matches the configured resource | ||
| """ | ||
| # Parse both URLs | ||
| requested = urlparse(requested_resource) | ||
| configured = urlparse(configured_resource) | ||
| requested = urlsplit(requested_resource) | ||
| configured = urlsplit(configured_resource) | ||
|
|
||
| # Compare scheme, host, and port (origin) | ||
| if requested.scheme.lower() != configured.scheme.lower() or requested.netloc.lower() != configured.netloc.lower(): | ||
|
Check notice on line 84 in src/mcp/shared/auth_utils.py
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing: default-port normalization asymmetry — the PRM Extended reasoning...A user configures OAuthClientProvider(server_url="https://api.example.com:443/mcp") — a spelling httpx treats as identical to the portless URL. The server's protected-resource metadata advertises resource "https://api.example.com/mcp" (or even "https://api.example.com:443/mcp" — pydantic strips the port when the client parses it either way, so str(prm.resource) is always portless). In Verification: pre_existing — The asymmetry is real: check_resource_allowed compares netloc strings verbatim (src/mcp/shared/auth_utils.py:84 |
||
| return False | ||
|
|
||
| # Normalize trailing slashes before comparison so that | ||
| # "/foo" and "/foo/" are treated as equivalent. | ||
| requested_path = requested.path | ||
| configured_path = configured.path | ||
| requested_path = _remove_dot_segments(requested.path) | ||
| configured_path = _remove_dot_segments(configured.path) | ||
| if not requested_path.endswith("/"): | ||
| requested_path += "/" | ||
| if not configured_path.endswith("/"): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟣 Pre-existing, left behind by this partial fix: the resource URL is now derived with dot-segments floored at the path root, but build_protected_resource_metadata_discovery_urls (src/mcp/client/auth/utils.py:90) still embeds the raw unresolved path into '/.well-known/oauth-protected-resource{path}' via urljoin, whose RFC 3986 resolution has no floor at the well-known prefix — a '..' that the new _remove_dot_segments correctly discards at root instead consumes the 'oauth-protected-resource' segment, so PRM discovery queries the wrong well-known URL for exactly the dot-segmented server_urls this PR sets out to handle (same pattern at utils.py:158 for AS metadata).
Extended reasoning...
A user configures OAuthClientProvider with server_url='https://host/a/../../b/mcp'. After this PR, resource_url_from_server_url correctly derives 'https://host/b/mcp' (the second '..' is floored at root by _remove_dot_segments). But path-based PRM discovery builds urljoin('https://host', '/.well-known/oauth-protected-resource/a/../../b/mcp'); CPython's urljoin pops segments with a bare resolved_path.pop(), so the second '..' removes 'oauth-protected-resource' and the client fetches 'https://host/.well-known/b/mcp' — never the RFC 9728 location 'https://host/.well-known/oauth-protected-resource/b/mcp'. On a multi-tenant server that only serves path-based PRM, discovery 404s, falls back to the root-based well-known, and _validate_resource_match then raises OAuthFlowError (or the client silently adopts the broader root PRM resource), even though the derived resource identifier is now correct. Fix: resolve dot-segments in server_url's path (e.g. reuse _remove_dot_segments / resource_url_from_server_url) before constructing the well-known discovery URLs.
Verification: pre_existing — the mechanism is real, but the base branch fails identically by the same route through untouched code. The diff (git diff 0cee624..HEAD) touches only src/mcp/shared/auth_utils.py and two test files; src/mcp/client/auth/utils.py is unchanged. At src/mcp/client/auth/utils.py:89-91, path-based PRM discovery embeds the raw, unresolved server path: `path_based_url = urljoin(base_url, f"