Skip to content

auth: reject redirect URIs with a query or fragment (MCP05) - #98

Merged
aterga merged 4 commits into
mainfrom
claude/reject-redirect-query-params
Jul 29, 2026
Merged

aterga merged 4 commits into
mainfrom
claude/reject-redirect-query-params

Conversation

@aterga

@aterga aterga commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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/authorize appends ?code=…&state=… to the redirect (build_redirect), so a redirect_uri that already carried a query produced code=123&code=456 parameter 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 (via redirect_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 classified invalid_request (a client-side request error) rather than routed to the "request access" page (which is for genuine approval gaps).
  • Tests: hosted_redirect_allow_list now asserts ?code=123, ?x=1, and #frag are 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

  • Nothing legitimate is rejected: every seeded vendor callback path and every registered loopback URI (per oauth-clients.json) is already query/fragment-free. The OAuth authorization server is the sole party that adds query params to the redirect.
  • The finding also says "consider not allowing fragments" — done (fragments are rejected too).

Testing

  • cargo build --locked --all-targets (via the test build)
  • cargo test --locked --all-targets — 141 lib + 7 router + 1 doctest passing
  • cargo clippy --all-targets — no new warnings in auth.rs
  • npm test --prefix monitoring/mcp-status — not applicable (no dashboard change; the DCR probes register query-free redirects, unaffected)

Checklist

  • I have read the Contributing guidelines.
  • Docs (README / comments) updated for the change.
  • No secrets, credentials, or internal-only information are included.

Generated by Claude Code

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
@aterga
aterga marked this pull request as ready for review July 29, 2026 12:37
@aterga
aterga requested review from a team and Copilot July 29, 2026 12:37

Copilot AI 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.

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_permitted to parse first and reject any query/fragment on both hosted and loopback redirects.
  • Update is_wellformed_hosted_redirect to treat query/fragment redirects as malformed/ineligible (so they surface as invalid_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.

Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
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
Copilot AI review requested due to automatic review settings July 29, 2026 12:45

Copilot AI 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.

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_redirect is used to decide whether to show the “request access” allow-list page vs classify the request as invalid_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 though redirect_uri_permitted rejects 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()

@aterga
aterga requested a review from sea-snake July 29, 2026 13:12
Comment thread src/auth.rs Resolved
sea-snake
sea-snake previously approved these changes Jul 29, 2026
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
Copilot AI review requested due to automatic review settings July 29, 2026 13:56

Copilot AI 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.

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_redirect is used to decide whether to show the "request access" allow-list page vs returning invalid_request, but it currently treats some redirects that redirect_uri_permitted will always reject (e.g., explicit non-default ports like :444 and 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_permitted currently 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/register and /oauth/authorize path for all hosted redirects, even though the same constraints can be checked directly on the already-parsed url.
    // 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

@aterga
aterga requested a review from sea-snake July 29, 2026 14:13
sea-snake
sea-snake previously approved these changes Jul 29, 2026
Comment thread src/auth.rs Resolved
…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
Copilot AI review requested due to automatic review settings July 29, 2026 15:01
@aterga
aterga requested a review from sea-snake July 29, 2026 15:04

Copilot AI 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.

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_url will currently return true for loopback URLs that include a query or fragment, even though redirect_uri_permitted now rejects query/fragment for all redirects. Tightening the loopback helper to also require query/fragment to 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/register and /oauth/authorize path. You can enforce the same “exactly https://<host><path>” constraint more cheaply by comparing against url.as_str() instead of reparsing.
    let Ok(canonical) = url::Url::parse(&format!("https://{host}{}", url.path())) else {
        return false;
    };
    if url != canonical {
        return false;

@aterga
aterga merged commit d17ce2c into main Jul 29, 2026
2 checks passed
aterga pushed a commit that referenced this pull request Jul 29, 2026
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
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.

4 participants