Repository navigation
Close auth-code phishing via a hosted-redirect allow-list - #69
Merged
Merged
Conversation
Open dynamic client registration let anyone register a client with a hosted redirect_uri they control, so the same-browser phishing variant that Consent-Bound Completion does not close could deliver a victim's authorization code to an attacker-registered hosted destination (CWE-601). Constrain hosted redirects to an allow-list of registrable domains; loopback stays exempt (a loopback code resolves on the consenter's own machine, so native/CLI clients are unaffected). - redirect_uri_permitted(): loopback always allowed; a hosted redirect must be https and its host must equal, or be a subdomain of, an allow-listed registrable domain. The host is read from the parsed URL, so authority tricks (https://claude.ai@evil.com, claude.ai.evil.com) resolve to their real host and are refused. - Seeded (DEFAULT_ALLOWED_REDIRECT_DOMAINS) with the MCP connector vendors seen in practice: antigravity.google, chatgpt.com, claude.ai, grok.com, perplexity.ai, perplexity.com. Widen per deployment with OAUTH_ALLOWED_REDIRECT_DOMAINS (additive), no rebuild. - Enforced at /oauth/register (reject before storing) and re-checked in redirect_allowed, so a pre-existing disallowed registration cannot be used at /oauth/authorize either. - Module docs + README updated (the same-browser variant is now closed). A test covers vendors/subdomains, loopback, the finding's payloads, and look-alike / authority-trick rejection. cargo test: 125 passing; no new clippy warnings. 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 mitigates authorization-code phishing enabled by open dynamic client registration by enforcing a hosted redirect_uri allow-list. It ensures only loopback redirects or HTTPS redirects to allow-listed domains (and their subdomains) can be registered and can receive authorization codes, closing the same-browser variant of the attack described in the PR metadata.
Changes:
- Added hosted-redirect domain allow-listing (
redirect_uri_permitted) and enforced it at both/oauth/registerand/oauth/authorize. - Introduced
DEFAULT_ALLOWED_REDIRECT_DOMAINSplus additive deploy-time widening viaOAUTH_ALLOWED_REDIRECT_DOMAINS. - Updated documentation and added/updated tests to cover allow-list behavior and stale registrations.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/auth.rs |
Implements hosted-redirect allow-listing, enforces it at registration and authorization, and adds tests. |
README.md |
Documents the hosted-redirect allow-list behavior and updates the companion-control note/roadmap text. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ation - allowed_redirect_domains(): parse each OAUTH_ALLOWED_REDIRECT_DOMAINS entry as a URL so scheme, userinfo, port, and path all reduce to the bare host, matching Url::host_str() at match time. Previously a port (e.g. vendor.example:8443) survived and could never match, silently rejecting a legitimate redirect. Docstring now holds. - README: terminate the /oauth/register bullet; split the dangling Roadmap parenthetical so the allow-list fact and the dropped device grant are separate sentences. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
aterga
commented
Jul 21, 2026
A client turned away by the hosted-redirect allow-list now gets an actionable next step instead of an opaque failure: - /oauth/authorize: a well-formed hosted redirect_uri that simply isn't allow-listed now renders an on-brand HTML page (not_allowlisted_page) naming the cause and a contact address, rather than a raw JSON invalid_client blob. This path is reached by clients that skip DCR or use a stored-but-disallowed registration; the standard flow is blocked earlier at /oauth/register. Other client-validation failures (unknown client_id, mismatched redirect) still return the terse JSON error. - /oauth/register: the JSON error_description now points the caller at the same contact so the client's setup UI can surface it. The page reuses the pinned callback page's self-contained shell (connect.css + inlined logo), carries no script, ships a strict nonce'd CSP (default-src 'none', no script-src), reflects no request input, and returns 403. Both themes verified via headless render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Per PR review: seed two more MCP client vendors so their hosted redirects register without a per-deployment OAUTH_ALLOWED_REDIRECT_DOMAINS override. Subdomains are covered by the existing suffix match (e.g. insiders.vscode.dev), asserted in the allow-list test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
Two Copilot review points on redirect_uri_permitted:
- Reject a non-empty username/password on hosted redirects (e.g.
https://user@claude.ai/cb). Userinfo serves no purpose in a redirect
target and only obscures which host is addressed; loopback already
rejects it, so this makes the two paths consistent.
- Replace the per-candidate format!(".{d}") in the subdomain check with
strip_suffix + a dot-boundary check, avoiding a string allocation per
allow-list entry on every /oauth/register and /oauth/authorize.
Same matching semantics; added userinfo-on-allowed-host test cases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
6 of 7 tasks
aterga
pushed a commit
that referenced
this pull request
Jul 21, 2026
… guard The OAuth Dynamic Client Registration check registered with a hosted redirect (https://example.org/callback) and expected a client_id. Since the allow-list shipped (#69), that hosted redirect is correctly rejected with 400 invalid_redirect_uri, so the check went red for a working server. - oauth-register: register a LOOPBACK redirect instead (always permitted; the allow-list exempts loopback), which still confirms a client can self-register and get a client_id, the case DCR is for. - oauth-register-allowlist (new): register a non-allow-listed hosted redirect and assert it's rejected with 400 invalid_redirect_uri, so the dashboard positively monitors that the CWE-601 guard stays enforced. - test harness: stubFetch now accepts a function route so a single endpoint can answer by request body; the /oauth/register mock mirrors the allow-list (201 for loopback, 400 for a hosted non-allow-listed redirect). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
aterga
added a commit
that referenced
this pull request
Jul 21, 2026
… guard (#72) The OAuth Dynamic Client Registration check registered with a hosted redirect (https://example.org/callback) and expected a client_id. Since the allow-list shipped (#69), that hosted redirect is correctly rejected with 400 invalid_redirect_uri, so the check went red for a working server. - oauth-register: register a LOOPBACK redirect instead (always permitted; the allow-list exempts loopback), which still confirms a client can self-register and get a client_id, the case DCR is for. - oauth-register-allowlist (new): register a non-allow-listed hosted redirect and assert it's rejected with 400 invalid_redirect_uri, so the dashboard positively monitors that the CWE-601 guard stays enforced. - test harness: stubFetch now accepts a function route so a single endpoint can answer by request body; the /oauth/register mock mirrors the allow-list (201 for loopback, 400 for a hosted non-allow-listed redirect). Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym Co-authored-by: Claude <noreply@anthropic.com>
5 of 7 tasks
aterga
pushed a commit
that referenced
this pull request
Jul 22, 2026
Restructure the binary into a library crate (imcp2) plus a thin deployment
binary, so one McpServer per Internet Identity instance mounts into any axum
application as two routers:
* McpServer::mcp_router() — the MCP streamable-HTTP endpoint (the router
fallback: the bare mount path, its trailing-slash form, and sub-paths all
reach it, bearer-gated + CORS'd) plus the OAuth authorization server under
/oauth (authorize, pinned connect-callback page, redeem, token, register)
and the OIDC-style AS-metadata alternate, nested at McpServer::mcp_path().
* McpServer::well_known_router() — the discovery documents at their
RFC 8414 / RFC 9728 §3.1 path-inserted well-known locations, merged at the
application root and parametric on the mount path: the AS issuer is
{public_url}{mcp_path} and every OAuth endpoint lives under it.
root_well_known_router() adds the plain-root fallback documents for the
origin's default instance, and auth_callbacks_router(&[…]) the
origin-global II auth-callback allow-list covering every instance.
The IC Agent is inherited from the embedding application (McpConfig::agent),
not built by the library, so a host passes its own boundary-node client and
the process links one ic-agent. Sourcing ic-agent through ic-bn-lib is
deferred until it releases against ic-agent 0.48 (its current release pins
0.47, which would revert the read-only delegation-permissions support);
recorded in Cargo.toml.
Consequences and companion changes:
* Both instances become path issuers with OAuth under their mounts:
beta /mcp + /mcp/oauth/* (issuer <PUBLIC_URL>/mcp — was root /oauth/* with
a root issuer), prod /mcp-prod + /mcp-prod/oauth/* (issuer
<PUBLIC_URL>/mcp-prod — was /prod/oauth/*). The 401 challenge points at
the path-inserted resource-metadata document; root discovery documents
are still served for the default (beta) instance as a courtesy. The
connect flow (binding cookie Path, II link callback, pinned page's redeem
URL, the ii-auth-callbacks entries) derives from the mount and is covered
by tests.
* IiInstance sheds oauth_prefix/mcp_path — it is purely "which II" now; the
mount path is deployment composition (McpConfig). PUBLIC_URL is no longer
read inside the library: the binary passes it in, and Identities receives
it for the management-identity derivation origin (unchanged value, so
derived principals are unaffected).
* The session reaper and live/active session gauges are exposed on
McpServer (spawn_session_reaper / session_gauges) for the binary's
/version probe; drained shutdown is preserved (drain, then shutdown()).
* Monitoring dashboard probes/tests and the READMEs updated to the new
endpoints; the register probe now uses a loopback redirect_uri (the
hosted-redirect allow-list from #69 rejects the old example.org probe —
a pre-existing red check against current main).
* New router-level contract tests (tests/routers.rs) pin the two-instance
composition: per-instance discovery documents and issuers, the
origin-global auth-callback document, path-aware challenges (incl.
trailing-slash/sub-path breadth and the RFC 6750 error attribute), and
per-mount OAuth endpoints incl. the pinned page's redeem URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
aterga
added a commit
that referenced
this pull request
Jul 23, 2026
* feat: package the MCP server as an embeddable library crate
Restructure the binary into a library crate (imcp2) plus a thin deployment
binary, so one McpServer per Internet Identity instance mounts into any axum
application as two routers:
* McpServer::mcp_router() — the MCP streamable-HTTP endpoint (the router
fallback: the bare mount path, its trailing-slash form, and sub-paths all
reach it, bearer-gated + CORS'd) plus the OAuth authorization server under
/oauth (authorize, pinned connect-callback page, redeem, token, register)
and the OIDC-style AS-metadata alternate, nested at McpServer::mcp_path().
* McpServer::well_known_router() — the discovery documents at their
RFC 8414 / RFC 9728 §3.1 path-inserted well-known locations, merged at the
application root and parametric on the mount path: the AS issuer is
{public_url}{mcp_path} and every OAuth endpoint lives under it.
root_well_known_router() adds the plain-root fallback documents for the
origin's default instance, and auth_callbacks_router(&[…]) the
origin-global II auth-callback allow-list covering every instance.
The IC Agent is inherited from the embedding application (McpConfig::agent),
not built by the library, so a host passes its own boundary-node client and
the process links one ic-agent. Sourcing ic-agent through ic-bn-lib is
deferred until it releases against ic-agent 0.48 (its current release pins
0.47, which would revert the read-only delegation-permissions support);
recorded in Cargo.toml.
Consequences and companion changes:
* Both instances become path issuers with OAuth under their mounts:
beta /mcp + /mcp/oauth/* (issuer <PUBLIC_URL>/mcp — was root /oauth/* with
a root issuer), prod /mcp-prod + /mcp-prod/oauth/* (issuer
<PUBLIC_URL>/mcp-prod — was /prod/oauth/*). The 401 challenge points at
the path-inserted resource-metadata document; root discovery documents
are still served for the default (beta) instance as a courtesy. The
connect flow (binding cookie Path, II link callback, pinned page's redeem
URL, the ii-auth-callbacks entries) derives from the mount and is covered
by tests.
* IiInstance sheds oauth_prefix/mcp_path — it is purely "which II" now; the
mount path is deployment composition (McpConfig). PUBLIC_URL is no longer
read inside the library: the binary passes it in, and Identities receives
it for the management-identity derivation origin (unchanged value, so
derived principals are unaffected).
* The session reaper and live/active session gauges are exposed on
McpServer (spawn_session_reaper / session_gauges) for the binary's
/version probe; drained shutdown is preserved (drain, then shutdown()).
* Monitoring dashboard probes/tests and the READMEs updated to the new
endpoints; the register probe now uses a loopback redirect_uri (the
hosted-redirect allow-list from #69 rejects the old example.org probe —
a pre-existing red check against current main).
* New router-level contract tests (tests/routers.rs) pin the two-instance
composition: per-instance discovery documents and issuers, the
origin-global auth-callback document, path-aware challenges (incl.
trailing-slash/sub-path breadth and the RFC 6750 error attribute), and
per-mount OAuth endpoints incl. the pinned page's redeem URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
* fix: address review — inherit routing for delegated calls, strict PKCE method, robust Host allow-list
Three review findings on the library extraction:
* Delegated/II calls now ride the injected agent's routing. Identities keeps
the base agent (McpConfig::agent) and every identity-bearing agent is a
clone of it with the identity swapped (Agent::set_identity — the clone
shares the transport, route provider, and root key). This removes every
hard-coded IC_URL rebuild (per-app calls, session/registration II calls,
the management agent), so an embedding that targets a non-default IC
endpoint routes ALL calls through it, not just anonymous ones.
* authorize rejects an omitted code_challenge_method. Per RFC 7636 an
omitted method defaults to `plain`, and the token endpoint only verifies
S256 — accepting the omission would hand a spec-strict `plain` client a
code it can never exchange. S256 must now be stated explicitly (it is the
only method the metadata advertises).
* The DNS-rebinding Host allow-list is derived by parsing public_url as a
URL (assuming https when scheme-less) instead of string-splitting, and
includes both the bare-host and host:port forms — a mis-parse here would
reject all public traffic before the bearer check. Covered by unit tests
for the scheme-less, explicit-port, and IPv6-literal shapes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
* test/fix: hermetic router tests; IPv6 loopback in the Host allow-list (Copilot review)
* tests/routers.rs: point OAUTH_CLIENTS_FILE at a throwaway temp path before
SharedClients::load(), so the router contract tests neither read nor write a
developer's real oauth-clients.json. The authorize-path assertions depend on
an empty registration set (unknown clients rejected); a stray real
registration for client_id=unknown/x would otherwise flip them.
* src/lib.rs: seed the DNS-rebinding Host allow-list with IPv6 loopback in both
the bare (::1) and bracketed ([::1]) spellings. A real Host header serializes
IPv6 bracketed, matching the host_str() form the public-host branch already
emits; carrying both keeps the list self-consistent and independent of rmcp's
bracket normalization. Unit test updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
* test: drop non-hermetic distinctness assertion in instance_defaults_are_valid (Copilot review)
IiInstance::beta()/prod() read II_URL* / II_CANISTER_ID* from the environment,
so asserting the two instances differ fails on a legitimate setup that points
both at the same II (e.g. local testing) or sets only one var set. The test's
real purpose is that the compile-time default canister ids parse into a
Principal, which the two .expect()s already validate; drop the assert_ne!.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
* chore: bump ic-agent (and lockstep ic-transport-types) to 0.49.0
No source changes needed — the 0.49 API surface this crate uses (Agent,
Agent::builder, set_identity, DelegatedIdentity/BasicIdentity and the
identity/delegation types, read_state_canister_metadata, query/update) is
unchanged from 0.48. Cargo.lock pins both ic-agent and ic-transport-types at
exactly 0.49.0 (kept in lockstep, as prior bumps were). Full suite green and
the binary boots and serves against 0.49.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
* fix: normalize public_url to a clean origin; case-insensitive Secure-cookie check (Copilot review)
Two related public_url-hygiene findings:
* McpServer::new now canonicalizes public_url to an origin (scheme://host[:port])
via normalize_public_url: adds https:// when scheme-less, lowercases the
scheme, drops the default port, and strips any path/query/fragment. It is the
single source of truth for the AS issuer, discovery docs, II callback, the
Secure-cookie decision, and the DNS-rebinding allow-list, so a scheme-less
host, an uppercase scheme, or a stray path previously produced malformed
issuers/callbacks. Self-corrects (no API break); falls back to the trimmed
input if a host can't be parsed. Unit-tested across those shapes.
* authorize's Secure-cookie check compares the scheme case-insensitively
(split_once("://") + eq_ignore_ascii_case) instead of starts_with("https://"),
so an uppercase scheme still yields Secure even for a directly-built AuthStore
(belt-and-suspenders on top of the normalization above).
Real deployments (PUBLIC_URL already a clean origin) are unaffected — the
normalization is a no-op there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017h4prMHkLo6UnwnuoPgmUj
---------
Co-authored-by: Claude <noreply@anthropic.com>
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
Open dynamic client registration let anyone register a client with a hosted
redirect_urithey control. Consent-Bound Completion closes the split-browser injection, but not the same-browser variant: a victim socially engineered into running the whole flow toward an attacker-registered hosted redirect delivers their authorization code to the attacker (who chose the PKCE pair), yielding a token that acts as the victim (CWE-601).This constrains hosted redirects to an allow-list of registrable domains, keyed on the parsed URL host. Loopback stays exempt (a loopback code resolves on the consenter's own machine), so native/CLI clients are unaffected. The allow-list is seeded from the connector vendors we expect to connect, and a client turned away is given an actionable next step rather than an opaque failure.
Related issues
Addresses an external security finding (CWE-601, "Open client registration enables authorization-code phishing"). No GitHub issue is open for it. The sibling SSRF finding (CWE-918) was already remediated by #35 and needs no change.
Changes
redirect_uri_permitted(): loopback (http://localhost/127.0.0.1/[::1], any port) is always allowed; a hosted redirect must behttpsand its host must equal, or be a subdomain of, an allow-listed registrable domain. The host comes from the parsed URL, so authority tricks (https://claude.ai@evil.com,https://claude.ai.evil.com) resolve to their real host and are refused.DEFAULT_ALLOWED_REDIRECT_DOMAINSseeds the list with the MCP client vendors we expect:antigravity.google,chatgpt.com,claude.ai,cursor.com,grok.com,perplexity.ai,perplexity.com,vscode.dev.OAUTH_ALLOWED_REDIRECT_DOMAINSwidens it per deployment (additive, comma/space-separated), no rebuild./oauth/register(rejected withinvalid_redirect_uribefore anything is stored) and re-checked inredirect_allowed, so a pre-existing disallowed registration can't be used at/oauth/authorizeeither (this neutralizes the staleexample.com/example.orgtest registrations without editing the store).mcp@dfinity.orgto request approval:/oauth/registersays so in its JSONerror_description, and a browser that reaches/oauth/authorizewith a non-allow-listed hosted redirect gets an on-brand HTML page (not_allowlisted_page, 403) instead of a raw error. The page reuses the pinned callback page's self-contained shell, carries no script, ships a strict nonce'd CSP (default-src 'none', noscript-src), and reflects no request input. Other client-validation failures (unknownclient_id, mismatched redirect) still return the terse JSONinvalid_client.hosted_redirect_allow_listcovers vendors + subdomains, loopback, the finding's exact payloads (example.com,attacker.example), and look-alike / authority-trick / non-https rejection;not_allowlisted_page_...andauthorize_allowlist_rejection_...cover the friendly-page contract (403, strict CSP, no script, no reflection) and the friendly-vs-JSON distinction.Testing
cargo build --locked(via the test build)cargo test --locked— 127 passingcargo clippy— 8 warnings remain, all pre-existing in files this PR does not touch (main.rs,calls.rs,discover.rs,management.rs); this change introduces none inauth.rsnpm test --prefix monitoring/mcp-status— not applicable (no dashboard change)Checklist
Rollout note
Every currently-registered client in the deployment is hosted (there are no loopback registrations yet), so this is safe because the seed list covers the real vendors; the two non-vendor registrations (
example.com,example.org) are test/probe entries and are the only existing ones this rejects.cursor.comandvscode.devare seeded ahead of those clients connecting. If a hosted connector on a domain not in the seed appears later, add it viaOAUTH_ALLOWED_REDIRECT_DOMAINSrather than redeploying.🤖 Generated with Claude Code
https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym