Skip to content

Pin allow-listed redirects to each vendor's callback path (CWE-601 hardening) - #73

Merged
aterga merged 6 commits into
mainfrom
claude/redirect-path-pinning
Jul 22, 2026
Merged

aterga merged 6 commits into
mainfrom
claude/redirect-path-pinning

Conversation

@aterga

@aterga aterga commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Hardens the hosted-redirect allow-list (from #69) against a real gap: it matched on the registrable domain only, but several allow-listed origins also serve third-party, script-capable content on the same origin — perplexity.ai/page/…, chatgpt.com/g/… and /share/…. Domain-only matching let an attacker register a redirect_uri on such a path; through the same-browser flow the victim's browser lands there with ?code=…, on-origin JS reads location and exfiltrates, and the attacker (the PKCE-self-satisfied client) exchanges the code for a token bound to the victim's principal. In effect the token-security invariant was outsourced to eight third parties' XSS hygiene.

This pins the path as well as the domain, so a registered redirect always lands on a vendor-controlled OAuth-callback endpoint, never a user-content path.

Credit: reported as a follow-up security finding on the #69 allow-list.

Related issues

Follows #69 (hosted-redirect allow-list, CWE-601). No GitHub issue open.

Changes

  • redirect_uri_permitted: each allow-list entry is now a (registrable domain, callback-path prefix) pair. A hosted redirect must be https, carry no userinfo, match an allow-listed domain/subdomain, and have a path within that vendor's pinned prefix. path_within_prefix checks a segment boundary, so /api/mcp/auth_callback admits /api/mcp/auth_callback and /api/mcp/auth_callback/… but not /api/mcp/auth_callbackEVIL.
  • Seeded from the real callback paths in oauth-clients.json: claude.ai /api/mcp/auth_callback, chatgpt.com /connector/oauth/, cursor.com /agents/mcp/oauth/callback (registered as www.cursor.com), perplexity.{ai,com} (+ www/staging/enterprise/n subdomains) /rest/connections/oauth_callback, grok.com (/connector/oauth/, /connectors-oauth-exchange-code/, /mcp/callback), antigravity.google /oauth-callback. All verified to match the actual registrations.
  • Dropped vscode.dev: its only registered path is /redirect — a web-to-desktop forwarding endpoint, i.e. the query-forwarding shape this finding warns about. Left out pending confirmation that vscode.dev/redirect doesn't let a caller steer where the ?code= goes. Easy to re-add as ("vscode.dev", "/redirect") if that endpoint is a safe fixed handoff.
  • Env override renamed OAUTH_ALLOWED_REDIRECT_DOMAINS → OAUTH_ALLOWED_REDIRECT_PREFIXES: entries are full https://host/path origins; an entry with no path (or root /) is refused with a warning, so ops can't reopen the domain-wide hole.
  • Tests: hosted_redirect_allow_list asserts the finding's payloads are rejected (perplexity.ai/page/…, www.perplexity.ai/page/…, chatgpt.com/g/…, chatgpt.com/share/…), plus wrong-path (claude.ai/foo, cursor.com/oauth/callback), the segment-boundary near-miss (…/auth_callbackEVIL), and that vscode.dev no longer passes; the allowed cases (incl. www.cursor.com/agents/mcp/oauth/callback) use each vendor's pinned path.
  • Module docs, the /oauth/register error message, and the README Companion-control note updated.

Notes / out of scope

  • claude.ai is the model done right — its artifacts execute on a separate sandboxed claudeusercontent.com origin that is deliberately not on the allow-list, so claude.ai itself never runs an attacker's JS.
  • Native (device-local) redirects are unaffected: loopback (http://127.0.0.1/localhost, any port) stays exempt, and Cursor's cursor://… private-use scheme is still rejected by the allow-list exactly as before this PR — the allow-list only gates hosted redirects, and Cursor also registers loopback + hosted callbacks. Supporting cursor://-style schemes per RFC 8252 would be a separate change (and must denylist javascript:/data: etc.).

Testing

  • cargo build --locked --all-targets (via the test build)
  • cargo test --locked — 128 passing
  • cargo clippy --all-targets — no new warnings in auth.rs
  • npm test --prefix monitoring/mcp-status — not applicable (no dashboard change; a path-pin monitor check could be a follow-up)

Checklist

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

Rollout note

OAUTH_ALLOWED_REDIRECT_DOMAINS is no longer read — deployments that set it must switch to OAUTH_ALLOWED_REDIRECT_PREFIXES with full https://host/path entries. Every current hosted registration matches a seeded prefix (verified against oauth-clients.json) except the two stale test entries (example.com/example.org, correctly rejected) and vscode.dev (dropped, see above); Cursor keeps working via its loopback callbacks and its now-pinned hosted callback.

🤖 Generated with Claude Code

https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

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 dynamic client registration and authorization by tightening the hosted redirect_uri allow-list from domain-only matching to vendor-specific domain + callback path prefix matching, closing a CWE-601 gap where user-content paths on allow-listed origins could capture authorization codes.

Changes:

  • Replace the hosted redirect allow-list from registrable domains to (domain, callback-path prefix) pairs, and enforce segment-boundary path-prefix matching.
  • Rename the deployment override from OAUTH_ALLOWED_REDIRECT_DOMAINS to OAUTH_ALLOWED_REDIRECT_PREFIXES and require full https://host/path entries (rejecting bare-domain/root-path entries).
  • Update tests and documentation to cover the newly rejected user-content paths and the pinned callback-path behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/auth.rs Implements domain+path pinned allow-list, env override rename/validation, and expands tests for the hardened redirect rules.
README.md Updates security documentation to reflect the path-pinned hosted-redirect allow-list and new env var name/format.

💡 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
The hosted-redirect allow-list matched on the registrable domain only.
But several allow-listed origins also serve third-party, script-capable
content on the SAME origin (perplexity.ai/page/..., chatgpt.com/g/... and
/share/...). Domain-only matching let an attacker register a redirect_uri
on such a path; via the same-browser flow the victim's browser lands there
with ?code=... and on-origin JS exfiltrates it, so the attacker (the PKCE-
self-satisfied client) can exchange the code for a token bound to the
victim's principal. The invariant was effectively outsourced to eight
third parties' XSS hygiene.

Fix: each allow-list entry now pins a (domain, callback-path-prefix). A
hosted redirect must be https, carry no userinfo, match an allow-listed
domain/subdomain, AND fall within that vendor's OAuth-callback path prefix
(segment-boundary checked, so /api/mcp/auth_callback does not admit
/api/mcp/auth_callbackEVIL). A registered redirect therefore always lands
on a vendor-controlled, non-user-content endpoint.

- Seeded from the vendors' real callback paths in oauth-clients.json:
  claude.ai /api/mcp/auth_callback, chatgpt /connector/oauth/, cursor.com
  /agents/mcp/oauth/callback (registered as www.cursor.com), perplexity
  /rest/connections/oauth_callback, grok's three paths, antigravity
  /oauth-callback.
- Dropped vscode.dev: its only registered path is /redirect, a
  web-to-desktop forwarding endpoint (the query-forwarding shape the
  finding warned about); leaving it out pending a decision on whether that
  endpoint safely handles the code. Re-add pinned if confirmed safe.
- Env override renamed OAUTH_ALLOWED_REDIRECT_DOMAINS ->
  OAUTH_ALLOWED_REDIRECT_PREFIXES: full https://host/path entries; a bare
  domain (root path) is refused so ops can't reopen the domain-wide hole.
- Tests assert the finding's payloads (perplexity.ai/page, chatgpt.com/g,
  /share) are rejected, plus wrong-path and segment-boundary near-miss.
- Module docs, register error, and README updated.

Note: loopback and Cursor's cursor:// private-use scheme are device-local
native redirects; loopback is already exempt, cursor:// is still rejected
(unchanged from before) since the allow-list only gates hosted redirects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
@aterga
aterga force-pushed the claude/redirect-path-pinning branch from 680ceff to a04ff99 Compare July 22, 2026 15:16
Two Copilot review points on the path-pinning change:

- allowed_redirects() reparsed OAUTH_ALLOWED_REDIRECT_PREFIXES and
  re-emitted its warnings on every /oauth/register and /oauth/authorize
  validation. Compute it once via OnceLock (the env is process-static),
  so no per-call parse/alloc or log spam; warnings fire once.
- Dot-segment bypass: a redirect like /connector/oauth/../../g/evil could
  satisfy the raw prefix check yet normalize (in the browser) outside the
  pinned prefix. Reject any `.`/`..` path segment (raw or %2e-encoded) via
  has_dot_segment before matching. url::Url already normalizes special-
  scheme paths, so this is belt-and-suspenders on the raw input; added a
  regression test on the payloads plus a direct has_dot_segment unit test.

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 22, 2026 20:35
Copilot AI review requested due to automatic review settings July 22, 2026 20:35

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 2 comments.

Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
…v prefixes

Two follow-ups from the path-pinning review, both reinforcing origin pinning:

- redirect_uri_permitted now refuses a hosted redirect that names an
  off-origin port (https://claude.ai:444/...): it is a different origin
  than the pinned host and could serve different content, so the path pin
  would not hold. url::Url::port() returns None for the scheme default, so
  the implicit port and :443 both stay allowed; loopback is unaffected.
- OAUTH_ALLOWED_REDIRECT_PREFIXES entries carrying a port, query, fragment,
  or userinfo are now refused (with a warning) instead of silently dropped,
  since only (host, path) is stored and matched. Parsing is extracted into
  parse_redirect_prefix for a direct unit test.

Tests: added off-origin-port cases to hosted_redirect_allow_list and a new
redirect_prefix_entry_parsing unit test. Full suite green.

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 22, 2026 20:55

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 4 comments.

Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
Comment thread src/auth.rs Resolved
Comment thread README.md Resolved
…fix docs

Follow-up wording nits from the re-review. The parser accepts an explicit
:443 (same origin as no port), but three doc/warning spots and the README
said "no port". Corrected them to "no non-default port (:443 is fine)", and
fixed the README to call an `https://host/path` entry a "URL prefix" rather
than an "origin" (an origin has no path). No behavior change.

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 22, 2026 21:14
…ed-list doc

Consistency follow-on to the README wording fix: the DEFAULT_ALLOWED_REDIRECTS
doc comment also called the env entries "origins", but an origin has no path.
Same wording as the README ("URL prefixes"). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

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 1 comment.

Comments suppressed due to low confidence (1)

src/auth.rs:242

  • parse_redirect_prefix currently rejects any URL that includes an explicit port via u.port().is_some(). In url::Url, an explicit :443 yields Some(443), so this code would incorrectly drop entries like https://vendor.example:443/mcp/callback even though the doc comment and unit test expect :443 to be accepted as same-origin.
    if u.port().is_some()

Comment thread src/auth.rs Resolved
Copilot AI review requested due to automatic review settings July 22, 2026 21:17

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 1 comment.

Comment thread README.md Resolved
The allow-list probe's comment still named the old OAUTH_ALLOWED_REDIRECT_DOMAINS
variable, which this branch renamed. Comment-only; the check is unchanged and
still asserts a `.invalid` hosted redirect is refused with 400
invalid_redirect_uri. Monitoring tests: 18 passing.

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 22, 2026 21:22

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 3 out of 3 changed files in this pull request and generated no new comments.

@aterga
aterga merged commit b0c3907 into main Jul 22, 2026
2 checks passed
aterga pushed a commit that referenced this pull request Jul 28, 2026
Follow-up reconciliation after #26 (library crate), #70, #73, #74 landed on
main. Verified against the current source; three concrete fixes:

- Deploy: the connect/landing HTML/CSS/SVG live in `src/assets/` (compiled in
  via include_str! from src/main.rs and src/auth.rs), not `static/`; `static/`
  holds only the reference docs. The old text conflated the two and contradicted
  the "Browser-facing error screens" section. Reframed as the deployment binary
  being self-contained, with both dirs as build-time inputs.
- Intro: ic-agent pin is 0.49 (Cargo.toml), not 0.48.
- Tools table: `canister_query` and `canister_update_call` take an optional
  `candid` arg (the .did fallback when a canister publishes no candid:service
  metadata); added it to both rows.

A wider audit found the rest of the doc already matches main (tool set,
endpoints, OAuth path-pinned allow-list, env vars, II methods, library API).

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.

3 participants