Skip to content

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

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

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
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