Skip to content

Close auth-code phishing via a hosted-redirect allow-list - #69

Merged
aterga merged 5 commits into
mainfrom
claude/hosted-redirect-allowlist
Jul 21, 2026
Merged

aterga merged 5 commits into
mainfrom
claude/hosted-redirect-allowlist

Conversation

@aterga

@aterga aterga commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Open dynamic client registration let anyone register a client with a hosted redirect_uri they 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 be https and 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_DOMAINS seeds 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_DOMAINS widens it per deployment (additive, comma/space-separated), no rebuild.
  • Enforced at /oauth/register (rejected with invalid_redirect_uri before anything is stored) and re-checked in redirect_allowed, so a pre-existing disallowed registration can't be used at /oauth/authorize either (this neutralizes the stale example.com/example.org test registrations without editing the store).
  • Friendly rejection. A client turned away by the allow-list is pointed at mcp@dfinity.org to request approval: /oauth/register says so in its JSON error_description, and a browser that reaches /oauth/authorize with 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', no script-src), and reflects no request input. Other client-validation failures (unknown client_id, mismatched redirect) still return the terse JSON invalid_client.
  • Module docs and README updated: the same-browser variant is documented as closed by the allow-list, with the contact path.
  • Tests: hosted_redirect_allow_list covers vendors + subdomains, loopback, the finding's exact payloads (example.com, attacker.example), and look-alike / authority-trick / non-https rejection; not_allowlisted_page_... and authorize_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 passing
  • cargo 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 in auth.rs
  • npm test --prefix monitoring/mcp-status — not applicable (no dashboard change)

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

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.com and vscode.dev are seeded ahead of those clients connecting. If a hosted connector on a domain not in the seed appears later, add it via OAUTH_ALLOWED_REDIRECT_DOMAINS rather than redeploying.

🤖 Generated with Claude Code

https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

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

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 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/register and /oauth/authorize.
  • Introduced DEFAULT_ALLOWED_REDIRECT_DOMAINS plus additive deploy-time widening via OAUTH_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.

Comment thread src/auth.rs Resolved
Comment thread README.md Resolved
Comment thread README.md Resolved
…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
Comment thread src/auth.rs Resolved
claude added 2 commits July 21, 2026 09:53
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
@aterga
aterga marked this pull request as ready for review July 21, 2026 10:23
Copilot AI review requested due to automatic review settings July 21, 2026 10:23

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

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

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

@aterga
aterga merged commit ca3b6cd into main Jul 21, 2026
2 checks passed
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>
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>
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