Skip to content

Resolve dot-segments when deriving and matching OAuth resource URLs - #3343

Open
maxisbey wants to merge 1 commit into
mainfrom
resource-url-dot-segments
Open

Resolve dot-segments when deriving and matching OAuth resource URLs#3343
maxisbey wants to merge 1 commit into
mainfrom
resource-url-dot-segments

Conversation

@maxisbey

Copy link
Copy Markdown
Contributor

Resolve dot-segments in resource_url_from_server_url() and check_resource_allowed() so the RFC 8707 resource identifier names the location the HTTP client actually contacts.

Motivation and Context

Fixes #3303.

resource_url_from_server_url() lowercased scheme/host and dropped the fragment but left the path as written, and check_resource_allowed() compared the raw paths with startswith. httpx resolves ./.. before sending, so for a server_url like https://host/a/mcp/../../b/mcp the request goes to /b/mcp while the derived resource identifier still reads /a/mcp/../../b/mcp, and a PRM resource of https://host/a/mcp prefix-matched it. The resource the client requests a token for should correspond to where it sends that token (RFC 8707 §2; RFC 9728 §3.3).

In the SDK's own call sites the server-supplied side (the PRM resource) is already normalised by pydantic, so this only changes outcomes when the client's configured URL itself contains dot-segments. It also brings the helper back in line with the TypeScript one it was ported from, where new URL() does this normalisation implicitly.

Changes:

  • _remove_dot_segments(): RFC 3986 §5.2.4, additionally treating %2E, .%2E, %2E. and %2E%2E as dot-segments (the WHATWG rule, which is what pydantic applies to the PRM side). %2F, empty segments and ; are left as written.
  • resource_url_from_server_url() applies it to the path.
  • check_resource_allowed() applies it to both paths, and parses with urlsplit instead of urlparse so ;params in the last segment stay part of the path rather than being dropped before the comparison.

How Has This Been Tested?

  • Unit tests for both helpers: parametrised dot-segment table, %2F / // / ; preserved, resolution applied to both sides, plus a property test that the resolver agrees with pydantic's WHATWG parser over every combination of literal . / .. / empty / plain segments up to depth 4.
  • Provider-level test: server_url=".../victim/mcp/../../m/mcp" rejects a PRM resource of .../victim/mcp, and get_resource_url() returns .../m/mcp.
  • Drove OAuthClientProvider through a real httpx2.AsyncClient against an in-process host whose /m tenant advertises resource=https://shared.example/victim/mcp. Before: the flow reached /authorize with resource=https://shared.example/victim/mcp. After: it stops at PRM validation with OAuthFlowError: Protected resource https://shared.example/victim/mcp does not match expected https://shared.example/m/mcp. Origin-root and exact-match PRMs behave as before.

Breaking Changes

None for ordinary URLs. resource_url_from_server_url() output changes for server URLs that contain ./.. segments (they are now resolved), and check_resource_allowed() no longer ignores ;params in the configured URL's last segment.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

The hierarchical prefix-matching policy itself is unchanged here. Whether the client should instead require an exact or origin-only match (as the Go and C# SDKs do, and as RFC 9728 §3.3 reads) is a separate question.

AI Disclaimer

resource_url_from_server_url() now applies RFC 3986 remove_dot_segments
(including the %2E spellings WHATWG treats as dots) so the resource
identifier names the location the HTTP client actually requests.

check_resource_allowed() resolves both paths the same way before its
prefix comparison, and parses with urlsplit so ";parameters" stay part
of the last path segment instead of being dropped.

Fixes #3303
@maxisbey
maxisbey marked this pull request as ready for review August 24, 2026 17:41

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 3 files

Re-trigger cubic

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, a few other concerns were examined and ruled out: treating the %2e/%2e%2e spellings as dot-segments does not make matching more permissive than the PRM side, since pydantic applies the same WHATWG normalization to the resource string before comparison (the exhaustive property test in tests/shared/test_auth_utils.py pins this agreement); .. is floored at the path root in _remove_dot_segments (the leading empty segment is never popped), so a request path cannot resolve above /; and %2F, empty segments, and ;params are correctly kept as segment content rather than boundaries.

Extended reasoning...

This is a security-relevant change to the RFC 8707 resource-indicator helpers in src/mcp/shared/auth_utils.py, so the inline findings already signal that a human should look; this note only records what else was checked. I traced _remove_dot_segments against the new tests: the len(output) > 1 guard preserves the leading empty segment produced by an absolute path, so .. cannot climb above the root (e.g. /../admin resolves to /admin), and splitting only on / leaves %2F, //, and ; in the last segment intact, matching the documented RFC 3986 behavior. The concern that recognizing WHATWG percent-encoded dot spellings could loosen PRM validation is settled by the property test comparing the resolver against pydantic's AnyHttpUrl parsing, which is exactly what normalizes the server-supplied resource side, so both operands of check_resource_allowed agree on resolution.

parsed._replace(
scheme=parsed.scheme.lower(),
netloc=parsed.netloc.lower(),
path=_remove_dot_segments(parsed.path),

Copy link
Copy Markdown
Contributor

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"

configured = urlsplit(configured_resource)

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: default-port normalization asymmetry — the PRM resource side is WHATWG-normalized by pydantic (explicit :443/:80 is stripped, e.g. AnyHttpUrl("https://host:443/mcp") serializes as https://host/mcp), but resource_url_from_server_url() (netloc kept verbatim at src/mcp/shared/auth_utils.py:54) and the netloc equality check in check_resource_allowed() (line 84) keep the explicit default port from the raw server_url string. The PR's stated goal is making both operands of check_resource_allowed agree on normalization (it fixed dot-segments for exactly this reason, per the committed property test asserting agreement with pydantic's WHATWG parser), but the default-port half of WHATWG normalization is still missing, so "host:443" != "host" fails the origin…

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 _validate_resource_match (src/mcp/client/auth/oauth2.py:576-578), default_resource is "https://api.example.com:443/mcp" while prm_resource is "https://api.example.com/mcp"; the netloc comparison at auth_utils.py:84 returns False and the OAuth flow aborts with OAuthFlowError: Protected resource https://api.example.com/mcp does not match expected https://api.example.com:443/mcp, even though both strings name the same location. The same asymmetry makes get_resource_url() (oauth2.py:210) never adopt the PRM resource for such configs. Fix in one place: strip the scheme's default port (or otherwise WHATWG-normalize the netloc) when deriving/comparing, mirroring what was just done for dot-s

Verification: pre_existing — The asymmetry is real: check_resource_allowed compares netloc strings verbatim (src/mcp/shared/auth_utils.py:84 requested.netloc.lower() != configured.netloc.lower()) and resource_url_from_server_url keeps the netloc as written (line 54), while the PRM side is resource: AnyHttpUrl (src/mcp/shared/auth.py:243), which pydantic v2 serializes with scheme-default ports stripped. The

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

check_resource_allowed(): path matching skips dot-segment/percent-encoding normalization (auth-boundary bypass)

1 participant