Repository navigation
auth: reject redirect URIs with a query or fragment (MCP05) - #98
Conversation
The redirect-URI allow-list validated scheme/host/path/port but accepted a query or fragment component. /oauth/authorize appends `?code=…&state=…` to the redirect (build_redirect), so a redirect that already carried a query produced `code=123&code=…` parameter pollution that a lax downstream parser could resolve to an attacker-seeded value. PKCE + the exact-registered-redirect match make it non-exploitable today, but disallowing query/fragment removes the ambiguity as defense in depth (MCP05, per the security review). - redirect_uri_permitted: parse first, reject any URI with a query or fragment, on loopback and hosted alike, before the existing checks. Enforced at both /oauth/register and /oauth/authorize (both route through this function). - is_wellformed_hosted_redirect: also exclude query/fragment, so a query-bearing hosted redirect is classified `invalid_request` (client error) rather than routed to the "request access" page. - Tests: hosted_redirect_allow_list now asserts `?code=`, `?x=1`, and `#frag` are refused on both a hosted vendor callback and loopback, and that the same URI without a query still passes. - README: the hosted-redirect allow-list note documents the query/fragment rule. Every seeded vendor callback and every registered loopback URI is already query/fragment-free, so this rejects nothing legitimate. cargo test: 141 lib + 7 router + 1 doctest passing; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
There was a problem hiding this comment.
Pull request overview
This PR hardens OAuth redirect-URI validation to address security finding MCP05 by rejecting redirect_uris that include a query string or fragment, preventing ambiguity/parameter pollution when the authorization endpoint appends ?code=…&state=….
Changes:
- Update
redirect_uri_permittedto parse first and reject any query/fragment on both hosted and loopback redirects. - Update
is_wellformed_hosted_redirectto treat query/fragment redirects as malformed/ineligible (so they surface asinvalid_request, not “request access”). - Extend allow-list tests and document the new rule in
README.md.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/auth.rs |
Enforces query/fragment rejection in redirect validation, adjusts hosted-redirect wellformedness classification, and adds test coverage for the new restriction. |
README.md |
Documents the “no query/fragment” redirect rule as part of the hosted-redirect allow-list description. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Two Copilot review nits on the MCP05 change: - redirect_uri_permitted parsed the URL, then called is_loopback_redirect, which re-parsed the same string (twice-parsed on the /oauth/register and /oauth/authorize path). Split out is_loopback_url(&Url) and call it on the already-parsed url; is_loopback_redirect(&str) now delegates to it (kept for loopback_match and the tests). - is_wellformed_hosted_redirect's doc summary now mentions the query/fragment exclusion, matching the implementation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/auth.rs:375
is_wellformed_hosted_redirectis used to decide whether to show the “request access” allow-list page vs classify the request asinvalid_request. Right now it treats redirects with an explicit port (e.g.https://host:444/...) or dot-segment traversal in the path as “well-formed hosted”, even thoughredirect_uri_permittedrejects both as ineligible. That misclassifies these cases as an approval gap (403 /invalid_client) instead of a client error (400 /invalid_request).
// A query or fragment makes it ineligible (MCP05), not merely
// unapproved, so it is classified `invalid_request` rather than
// routed to the "request access" page.
&& url.query().is_none()
&& url.fragment().is_none()
Per review (sea-snake): prefer allow-listing the URL's permitted shape over denylisting each disallowed component. Replaced the separate scheme/userinfo/ port "is-not-present" checks in redirect_uri_permitted with a single positive check: rebuild the canonical `https://<host><path>` from the parts we allow and require the parsed input to equal it. Any userinfo, off-origin port, http scheme, or other unexpected component now makes the two differ and is refused in one comparison. Kept separate (they can't be a single equality): the raw-string dot-segment check (url::Url normalises `.`/`..` away on parse, so the parsed path wouldn't reveal them) and the host/path-prefix allow-list match (the path is a prefix, not a fixed value). Query/fragment are still rejected up front for loopback and hosted alike (MCP05). Behavior-equivalent: hosted_redirect_allow_list (userinfo, :443/:444, query, fragment, dot-segments, wrong-path, look-alikes, http) all pass. cargo test: 141 lib + 7 router + 1 doctest green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/auth.rs:375
is_wellformed_hosted_redirectis used to decide whether to show the "request access" allow-list page vs returninginvalid_request, but it currently treats some redirects thatredirect_uri_permittedwill always reject (e.g., explicit non-default ports like:444and dot-segment paths) as “well-formed hosted”. That can misclassify an ineligible redirect as an approval gap and show a misleading page / wrong error taxonomy. Consider aligning the “well-formed hosted” predicate with the hosted-shape constraints by rejecting non-default ports and./..segments here too.
url.scheme() == "https"
&& url.username().is_empty()
&& url.password().is_none()
&& url.host_str().is_some_and(|h| !h.is_empty())
// A query or fragment makes it ineligible (MCP05), not merely
src/auth.rs:345
redirect_uri_permittedcurrently reparses a newly formatted URL just to enforce the allowed hosted “shape” (https, no userinfo, no explicit non-default port). This adds an extra allocation + parse on the/oauth/registerand/oauth/authorizepath for all hosted redirects, even though the same constraints can be checked directly on the already-parsedurl.
// Allowlist the redirect's SHAPE rather than denylisting each disallowed part: a
// permitted hosted redirect serialises to exactly `https://<host><path>` (https,
// no userinfo, no port, no query, no fragment). Rebuild that canonical form from
// the parts we allow and require an exact match, so any unexpected component is
// refused in one comparison. url::Url drops the https-default `:443` (so it still
…ation (review) Per review (sea-snake): the explicit has_dot_segment guard duplicates what url::Url::parse already does. Verified against this crate's url version that parse collapses every dot-segment form to the normalized path — raw `..`, `%2e%2e`, `%2E%2E`, and mixed `.%2e` all yield `/g/evil` — following the WHATWG URL Standard. So `.../connector/oauth/../../g/evil` reaches the matcher as `/g/evil` and is already rejected by the pinned-prefix check; the raw-string guard caught nothing extra. - Removed has_dot_segment and its unit test. - The pinned-prefix comment now notes that url.path() is already dot-segment-normalized, so traversal is caught by the prefix check. - hosted_redirect_allow_list keeps the traversal payloads as end-to-end regression assertions (raw + `%2e%2e` + `%2E%2E`, all rejected) — these now double as a guard that the parser keeps normalizing — and adds a case showing a dot-segment that normalizes to WITHIN the prefix is (correctly) allowed, which the old blanket guard wrongly rejected. cargo test: 140 lib + 7 router + 1 doctest green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/auth.rs:1747
is_loopback_redirect/is_loopback_urlwill currently return true for loopback URLs that include a query or fragment, even thoughredirect_uri_permittednow rejects query/fragment for all redirects. Tightening the loopback helper to also requirequery/fragmentto be absent keeps these helpers consistent with the new invariant and reduces the chance of future misuse in other call sites/tests.
url.scheme() == "http"
&& url.username().is_empty()
&& url.password().is_none()
&& matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
}
src/auth.rs:325
- This canonical-shape check reparses a freshly formatted URL (
Url::parse(...)) for every hosted redirect, adding a second URL parse on the/oauth/registerand/oauth/authorizepath. You can enforce the same “exactlyhttps://<host><path>” constraint more cheaply by comparing againsturl.as_str()instead of reparsing.
let Ok(canonical) = url::Url::parse(&format!("https://{host}{}", url.path())) else {
return false;
};
if url != canonical {
return false;
Copilot noted the "verified, file:line" citations had drifted from src/auth.rs. The doc's numbers were taken from the pre-merge #98 branch; after #98 (the MCP05 redirect fix) merged, everything below redirect_uri_permitted shifted by about nine lines. Re-synced every citation against current main and verified each against the source: authorize 865 -> 874 validate_client 890 (call) -> 759 (definition) #4091 well-known 1082-1109 -> 1091-1118 error copy 923 -> 932 ii_mcp_url 1062 -> 1071 connect_callback_url 1088 -> 1097 auth_callbacks 1097 -> 1106 logo include_str! 1161 -> 1170 register 1888 -> 1897 client-{uuid} 1913 -> 1922 Unchanged (above the shift): make_room_for_client (340), DEFAULT_ALLOWED_REDIRECTS (399), the path-pin comment (383-391), and the loopback-exempt note (393). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Summary
Addresses security finding MCP05: "Redirect URL with query parameters can be injected" (rated Low). The redirect-URI allow-list validated scheme/host/path/port but accepted a query or fragment component.
/oauth/authorizeappends?code=…&state=…to the redirect (build_redirect), so aredirect_urithat already carried a query producedcode=123&code=456parameter pollution that a lax downstream parser (the AI provider / client) could resolve to an attacker-seeded value.Not exploitable today — PKCE plus the exact-registered-redirect match (an attacker can't overwrite an existing client's
redirect_uri) block it — but, per the finding's recommendation, disallowing query and fragment removes the ambiguity as defense in depth against a future weakening (e.g. PKCE removed, or looser redirect matching) or a permissive client-side parser.Related issues
Security review finding MCP05. Follows the hosted-redirect allow-list work (#69, #73). No GitHub issue open.
Changes
redirect_uri_permitted: parse the URI first, then reject any URI carrying a query or fragment — on loopback and hosted alike — ahead of the existing checks. This function gates both/oauth/register(per-URI validation) and/oauth/authorize(viaredirect_allowed), so the rule holds at registration and at code issuance.is_wellformed_hosted_redirect: also exclude query/fragment, so a query-bearing hosted redirect is classifiedinvalid_request(a client-side request error) rather than routed to the "request access" page (which is for genuine approval gaps).hosted_redirect_allow_listnow asserts?code=123,?x=1, and#fragare refused on both a hosted vendor callback and a loopback URI, and that the same URI without a query still passes.README.md: the hosted-redirect allow-list note documents the query/fragment rule.Notes
oauth-clients.json) is already query/fragment-free. The OAuth authorization server is the sole party that adds query params to the redirect.Testing
cargo build --locked --all-targets(via the test build)cargo test --locked --all-targets— 141 lib + 7 router + 1 doctest passingcargo clippy --all-targets— no new warnings inauth.rsnpm test --prefix monitoring/mcp-status— not applicable (no dashboard change; the DCR probes register query-free redirects, unaffected)Checklist
Generated by Claude Code