Repository navigation
Pin allow-listed redirects to each vendor's callback path (CWE-601 hardening) - #73
Merged
Merged
Conversation
There was a problem hiding this comment.
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_DOMAINStoOAUTH_ALLOWED_REDIRECT_PREFIXESand require fullhttps://host/pathentries (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.
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
force-pushed
the
claude/redirect-path-pinning
branch
from
July 22, 2026 15:16
680ceff to
a04ff99
Compare
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
…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
…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
…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
There was a problem hiding this comment.
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_prefixcurrently rejects any URL that includes an explicit port viau.port().is_some(). Inurl::Url, an explicit:443yieldsSome(443), so this code would incorrectly drop entries likehttps://vendor.example:443/mcp/callbackeven though the doc comment and unit test expect:443to be accepted as same-origin.
if u.port().is_some()
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
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
6 of 7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 aredirect_urion such a path; through the same-browser flow the victim's browser lands there with?code=…, on-origin JS readslocationand 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 behttps, carry no userinfo, match an allow-listed domain/subdomain, and have a path within that vendor's pinned prefix.path_within_prefixchecks a segment boundary, so/api/mcp/auth_callbackadmits/api/mcp/auth_callbackand/api/mcp/auth_callback/…but not/api/mcp/auth_callbackEVIL.oauth-clients.json:claude.ai/api/mcp/auth_callback,chatgpt.com/connector/oauth/,cursor.com/agents/mcp/oauth/callback(registered aswww.cursor.com),perplexity.{ai,com}(+www/staging/enterprise/nsubdomains)/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.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 thatvscode.dev/redirectdoesn'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.OAUTH_ALLOWED_REDIRECT_DOMAINS→OAUTH_ALLOWED_REDIRECT_PREFIXES: entries are fullhttps://host/pathorigins; an entry with no path (or root/) is refused with a warning, so ops can't reopen the domain-wide hole.hosted_redirect_allow_listasserts 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./oauth/registererror message, and the README Companion-control note updated.Notes / out of scope
claude.aiis the model done right — its artifacts execute on a separate sandboxedclaudeusercontent.comorigin that is deliberately not on the allow-list, soclaude.aiitself never runs an attacker's JS.http://127.0.0.1/localhost, any port) stays exempt, and Cursor'scursor://…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. Supportingcursor://-style schemes per RFC 8252 would be a separate change (and must denylistjavascript:/data:etc.).Testing
cargo build --locked --all-targets(via the test build)cargo test --locked— 128 passingcargo clippy --all-targets— no new warnings inauth.rsnpm test --prefix monitoring/mcp-status— not applicable (no dashboard change; a path-pin monitor check could be a follow-up)Checklist
Rollout note
OAUTH_ALLOWED_REDIRECT_DOMAINSis no longer read — deployments that set it must switch toOAUTH_ALLOWED_REDIRECT_PREFIXESwith fullhttps://host/pathentries. Every current hosted registration matches a seeded prefix (verified againstoauth-clients.json) except the two stale test entries (example.com/example.org, correctly rejected) andvscode.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