Skip to content

Surface vetted-connector branding to Internet Identity, bound to the authorization session - #200

Merged
aterga merged 10 commits into
mainfrom
feat/cimd-connector-branding
Nov 10, 2026
Merged

aterga merged 10 commits into
mainfrom
feat/cimd-connector-branding

Conversation

@aterga

@aterga aterga commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Note for reviewers: this branch also carries #237's one-line test fix in crates/imcp2-core/src/discover.rs. oisy.com started declaring its own derivation origin, which broke a live-network test on main and in every PR's CI. It's the same commit as #237, so it drops out of this diff once #237 lands.

What

This is the imcp2 side of client branding. Internet Identity's consent screen can then show which vetted product is asking for a connect, instead of granting II accounts to an anonymous "some bridge". It complements the CIMD registration mode (#191).

Design

  • Server-curated connector table in the new src/branding module. It covers ChatGPT, Claude, Cursor, Grok, Perplexity and Google Antigravity.
    • It is keyed on the session's validated redirect_uri vendor, never on the client-supplied client_name/logo_uri. Open DCR takes all callers, so client-supplied identity is attacker-controlled.
    • Anyone may register a vetted redirect, but the path-pinned allow-list delivers the code only to that vendor's callback. The redirect vendor is therefore a sound, server-determined proxy for product identity.
    • Only a redirect admitted by a compiled-in DEFAULT_ALLOWED_REDIRECTS entry is branded (redirect_uri_on_default_allow_list). An OAUTH_ALLOWED_REDIRECT_PREFIXES entry works for redirects but is never branded, even when it's on a vendor's domain, so a vendor's name only ever rests on the reviewed callback paths in this repo.
    • A test holds the curated domains equal to the compiled-in allow-list vendors.
  • Session-bound metadata. GET {issuer}/branding?state=<state> → { name, logo, verified }, CORS-open. Every response, including the 404, is no-store.
    • II passes the state it already holds from the connect link.
    • The server looks up that pending, unexpired connect and derives the connector from the redirect that authorize validated.
    • Nothing in the connect-link fragment asserts branding. The fragment is craftable by any native client that drives the navigation, so a slug there would let a loopback session claim to be "verified Claude".
    • A loopback or unlisted session gets 404, as does an unknown, missing, expired or malformed state. They are indistinguishable, so the endpoint is no session oracle.
    • The lookup is read-only and cannot disturb the handshake.
  • Static logo. GET {issuer}/branding/{slug}/logo → SVG with nosniff, a cache header, and a sandboxing CSP (default-src 'none'; style-src 'unsafe-inline'; sandbox). It returns 404 outside the curated set.
    • II must render it via <img> and never inline it, because an inlined SVG can run script.
    • The CSP covers the case where the logo URL is opened top-level, where it would otherwise be an SVG document on the issuer origin. A test keeps every bundled logo free of script, event handlers, embedded HTML and external references.
  • One host rule. Redirect validation, the allow-list env parser, the CIMD trust match and branding all use host_key + host_is_or_under, so they cannot disagree about a vendor.
    • For example, https://claude.ai./… (accepted by validation) brands as Claude.
    • A redirect that validation would refuse never resolves to a connector.

Logos are placeholders

The bundled logo is an original, neutral "verified connector" mark. It is deliberately not a reproduction of any vendor's logo, because that is a trademark and licensing matter this repo shouldn't decide. Each vendor's licensed mark can go into its Connector::logo; the factual product names ship now.

Not yet visible: needs II's counterpart

II has to:

  • fetch /branding?state= from the validated callback origin, using the one state it parsed from the link for both the lookup and the callback;
  • show the verified treatment only for imcp2 issuer origins that II itself trusts.

Until that ships, the endpoints are inert.

Tests

  • Unit:
    • redirect → connector resolution, including subdomains, a trailing dot and uppercase;
    • rejection of look-alike apexes, loopback, unlisted hosts, http, redirects validation refuses, and operator-added entries on a vendor's domain;
    • bundled logos are static SVG (no script, handlers, embedded HTML or external references);
    • closed slug lookup;
    • curated domains equal to allow-list domains.
  • Unit, branding_is_bound_to_the_session: a vetted session → 200; native → 404; unknown or missing → 404; expired → 404; the lookup leaves the pending connect untouched.
  • II link: it carries no connector param.
  • Router:
    • The DCR round-trip test drives branding over HTTP: authorize → state from II's link → /branding?state= → 200 Claude + no-store for both claude.ai spellings, and 404 for the loopback session. It is folded into the one accepting-registration test so it can't race the shared store file.
    • No session → 404 with CORS and no-store; the old unbound /branding/{slug} metadata route is gone; the logo is served as SVG with nosniff and the sandboxing CSP.
  • Full workspace green; no new clippy warnings in the root crate.

🤖 Generated with Claude Code

Implements the imcp2 side of the client-branding extension
(docs/scoping-client-branding.md, PR #103): let II show WHICH vetted
product is asking for a connect, instead of an anonymous "some bridge".

- New `branding` module: a server-curated table of vetted connectors
  (ChatGPT, Claude, Cursor, Grok, Perplexity, Google Antigravity), keyed on
  the request's already-validated `redirect_uri` vendor — never on the
  client-supplied `client_name`/`logo_uri`, which open DCR makes
  attacker-controlled (rendering them would be a consent-phishing gift). A
  connect that doesn't resolve to a vetted web vendor — including every
  loopback (native-app) redirect — keeps the status-quo anonymous screen.
- `authorize` resolves the connector for the validated redirect and passes
  its slug on the II connect link as `&connector=<slug>` (additive fragment
  param; an II that doesn't know it ignores it).
- Two issuer-rooted GET endpoints II fetches same-origin with the
  #4091-validated callback: `/branding/{slug}` (name + absolute logo URL +
  `verified`, `no-store`) and `/branding/{slug}/logo` (SVG, `nosniff`).
  Both 404 outside the curated set so the path can't probe.

The bundled logos are ORIGINAL neutral placeholders (a generic "verified
connector" mark), deliberately not reproductions of vendor logos — each
vendor's own licensed mark is dropped into its `Connector::logo` later.
Actually rendering this needs II's counterpart change (parse the slug,
fetch these endpoints, render); until then it is inert and harmless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aterga
aterga requested review from a team and a balanced review from Copilot September 23, 2026 21:30

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.

Copilot review overview

🟡 Changes recommended

Branding is not bound to its authorization session, allowing an attacker-crafted fragment to claim another vetted connector.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds vetted connector branding for Internet Identity consent screens.

Changes:

  • Maps validated vendor redirects to curated connector branding.
  • Adds branding metadata/logo endpoints.
  • Passes optional connector slugs through II connect URLs with tests.
File Description
src/​branding.rs Defines branding catalog and endpoints.
src/​auth.rs Resolves branding during authorization.
src/​lib.rs Registers branding routes.
crates/​imcp2-core/​src/​iiconnect.rs Adds connector URL parameter.
crates/​imcp2-local/​src/​login/​mod.rs Omits branding for local login.
tests/​routers.rs Tests branding endpoints.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/branding.rs Resolved
Comment thread src/branding.rs Resolved
The first cut let the connect-link fragment carry `&connector=<slug>` and
served `GET {issuer}/branding/{slug}` unconditionally. The fragment is
craftable by any native client that drives the navigation, so a loopback
session could have presented itself to Internet Identity as "verified
Claude".

Branding is now answered per session: II asks `GET {issuer}/branding?state=`
with the `state` it already holds, and the server derives the connector from
that pending, unexpired connect's validated `redirect_uri`. Nothing in the
fragment asserts branding (the connector param is gone from `ii_mcp_url`),
and a loopback or unlisted session gets the same 404 as an unknown one. The
lookup is read-only, so it cannot disturb the handshake.

Branding and redirect validation now share one host rule (`host_key` +
`host_is_or_under`), so a trailing-dot `claude.ai.` redirect, which
validation accepts, brands as Claude instead of reading as anonymous; the
allow-list parser and the CIMD trust match use the same helpers. A redirect
validation would refuse never resolves to a connector, and a test holds the
curated domains equal to the compiled-in allow-list vendors.

The DCR router test now drives the 200 path over HTTP (authorize -> the
`state` in II's link -> `/branding?state=`), including `no-store` and both
claude.ai spellings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 24, 2026 12:04
@aterga aterga changed the title Surface vetted-connector branding to Internet Identity (name + logo) Surface vetted-connector branding to Internet Identity, bound to the authorization session Sep 24, 2026

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.

Copilot review overview

🟡 Changes recommended

The implemented session-bound API conflicts with the documented Internet Identity integration contract.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
Resolved since last review (2)

Comment thread src/lib.rs Resolved
Comment thread src/branding.rs Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 24, 2026 12:09

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.

Copilot review overview

🟢 Approval recommended

The implementation is securely session-bound and well tested; only a minor public-rustdoc omission remains.

Review effort: Balanced
Findings: None

Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Low severity Update mcp_router rustdoc for branding routes and fallback scope

src/​lib.rs:341

The public mcp_router rustdoc at lines 231–244 still lists only the OAuth and metadata routes and says “everything else” reaches the MCP fallback. This new public route makes that contract inaccurate; document both branding endpoints (including their CORS behavior) and qualify the fallback as applying only to unmatched paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 24, 2026 13:13

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.

Copilot review overview

🟢 Approval recommended

The implementation matches the revised session-bound design and is covered by focused unit and router tests.

Review effort: Balanced
Findings: None

connector_for_redirect accepted anything the effective allow-list admits,
so an OAUTH_ALLOWED_REDIRECT_PREFIXES entry on a vendor's domain (another
path on claude.ai, say) was shown as that vendor, contrary to the docs.
Branding now requires a redirect admitted by a compiled-in
DEFAULT_ALLOWED_REDIRECTS entry, under validation's own rules
(redirect_uri_on_default_allow_list, sharing hosted_redirect_admitted with
redirect_uri_permitted), so a vendor's name only ever rests on the reviewed
callback paths in this repo.

The branding 404 is now no-store like the 200 it stands in for, and the logo
carries a sandboxing CSP: opened top-level it is an SVG document on the
issuer origin, where <img> isolation does not apply. A test keeps every
bundled logo free of script, event handlers, embedded HTML, and external
references, for when licensed vendor marks replace the placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 24, 2026 14:53

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.

Copilot review overview

🟢 Approval recommended

The session binding, redirect vetting, caching, routing, and SVG safeguards are coherent and well tested.

Review effort: Balanced
Findings: None

The logo test banned a handful of literal substrings, so a spaced
`href = "https://…"` or a quoted `url('https://…')` got through. It now
strips whitespace first and requires every href, src, and url() target to
be an internal `#…` reference, and also rejects XHTML, animation, and DTDs.
It checks itself against a set of unsafe SVGs, spaced and quoted spellings
included, so the guard can't quietly weaken when licensed vendor marks
replace the placeholder.

The module doc now says what branding vouches for: where the authorization
code is delivered, not who started the connect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings September 24, 2026 15:03
aterga added a commit that referenced this pull request Sep 24, 2026
The first draft put the product slug in the connect-link fragment
(`&connector=<slug>`) and served an unbound `GET {issuer}/branding/{slug}`.
The fragment is written by whoever drives the browser, so that let a
consent screen vouch for a product the server never tied to the connect.

The contract now matches what #200 implements: II asks
`GET {issuer}/branding?state=` and the server answers from the connect's
validated redirect, with nothing about branding in the link. The revision
also:

- states precisely what branding vouches for (where the authorization code
  is delivered, not who started the connect or which vendor account ends up
  holding the grant) and the residual risks that follow;
- spells out the II side as numbered requirements: issuer derivation from
  the validated callback, response acceptance, one callback and one state
  for everything, an exact-origin trust list gating the whole treatment,
  and safe rendering (CSP, <img>, wording);
- notes that branding covers only compiled-in callbacks, never operator
  allow-list entries, and documents the logo hardening;
- replaces line-number citations with symbol references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

Copilot review overview

🟢 Approval recommended

Branding is securely session-bound, consistently validated, and comprehensively tested.

Review effort: Balanced
Findings: None

aterga added a commit that referenced this pull request Oct 2, 2026
CIMD shipped in #191 (opt-in) and #203 (on by default, with
OAUTH_CIMD_ENABLED as a kill switch), so the scoping plan now records
what was built instead of proposing it:

- the implemented gate, flow, fetcher, validation, caching, single-flight
  and in-flight bounds, configuration and rollback, by symbol rather than
  by line number;
- answers to the plan's open questions, and where the build departed from
  the plan: a domain-and-subdomain gate instead of exact origins, no cache
  floor or ETag revalidation, the same-origin redirect rule, discovery's
  SSRF guard tightened rather than left unchanged, and the rate cap
  dropped on purpose;
- branding moved to #103/#200 and keyed on the validated redirect. A
  client_id-domain key would have let any local program pose as a
  loopback CIMD client of a vetted vendor;
- the DoS residuals as built, the tests that pin the behaviour, and an
  index of where it lives.

Section numbers 3.4 and 3.5, which the code cites, keep their meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aterga added a commit that referenced this pull request Oct 5, 2026
… and #203) (#143)

* docs: scope CIMD (Client ID Metadata Documents) to replace open DCR

Expands the top-ranked alignment improvement into an implementable plan.
Recommends a trust-policy-gated, additive design: fetch a Client ID
Metadata Document only when the client_id URL's host is already on the
curated vendor allow-list, keep open DCR for everything else, and never
trust the document's display fields. This collapses the new outbound-fetch
surface to a finite set of vetted hosts instead of an arbitrary-URL SSRF
primitive on the unauthenticated /authorize path, while still delivering
spec alignment and a DNS/TLS-authenticated domain key for branding.

Folds in the alignment PR's review correction: skills.rs's
markdown_url_for_base is NOT a usable SSRF guard (host-string compare
only); the real building block is discover.rs's address-pinned fetcher
(resolve_public_url + site_client + ssrf_redirect_policy), which the plan
extracts into a shared module as its Phase 0. Covers the fetch/validate/
cache flow, allow-list re-keying, branding subsumption, a security
analysis, phasing, and open questions — all cited against current code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CIMD doc: origin-exact gating, strict reader, string match, mandatory rate cap

Address the review on the CIMD scoping doc:

* Trust policy matches exact HTTPS ORIGINS, not bare hosts. resolve_public_url
  uses the caller-supplied port (discover.rs:1113), so a host-only gate would
  let https://claude.ai:8443/... reach an unvetted port; require the default
  443 (and reject userinfo/other non-canonical authority) before any DNS/fetch.
* CIMD must use a STRICT capped reader, not discovery's best-effort one
  (discover.rs:1207-1228 returns lossy/partial text without signaling — a
  truncated body whose prefix is valid JSON would be accepted as metadata at
  the auth boundary). Fail closed on over-limit/stream-error/invalid-UTF-8.
* client_id validation is a plain STRING match against the requested URL — no
  normalization (hosted redirects use exact string membership, auth.rs:648, and
  normalizing would mint aliases that disagree with the raw-URL cache key).
* Make the outbound-DoS control mandatory: the cache does NOT bound misses,
  because an attacker can vary the URL PATH on a vetted host to mint unlimited
  distinct keys and concurrent 15s fetches. A per-host + global concurrency/rate
  cap (plus negative-caching) is now a Phase 1 acceptance criterion, not an
  optional nicety.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CIMD doc: disable cross-origin redirects (discovery policy leaks the gate)

Review fix: the doc said to keep discovery's redirect policy, but
ssrf_redirect_policy / redirect_hop_ok (discover.rs:1145-1153) follow any
global-IP literal and any same-host hop WITHOUT a port check — so a vetted
client_id could redirect the fetch to an unvetted public IP or to
vetted-host:8443, escaping the exact-origin gate of §3.1. A CIMD is served
directly at its URL, so §3.3 now says to disable redirects entirely (or, if
truly needed, require an exact same-origin hop), never the discovery policy.
§5's SSRF bullet notes the control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CIMD doc: never negative-cache per-request redirect failures (poisoning)

Review fix: §3.4 folded request-specific redirect checks into "validation,"
so negative-caching every validation failure by URL would let an attacker
poison a valid client — request a real CIMD URL with a non-member
redirect_uri, the URL gets cached as invalid, and legitimate redirects then
hit the negative entry. Split validation into document-intrinsic (cacheable:
fetch/JSON/client_id==URL) and per-request (never cached: redirect membership
+ redirect_uri_permitted, re-run every request against the positively-cached
document). §3.5 and the §5 DoS bullet now negative-cache only
document-intrinsic failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: turn the CIMD scoping into a design record of what shipped

CIMD shipped in #191 (opt-in) and #203 (on by default, with
OAUTH_CIMD_ENABLED as a kill switch), so the scoping plan now records
what was built instead of proposing it:

- the implemented gate, flow, fetcher, validation, caching, single-flight
  and in-flight bounds, configuration and rollback, by symbol rather than
  by line number;
- answers to the plan's open questions, and where the build departed from
  the plan: a domain-and-subdomain gate instead of exact origins, no cache
  floor or ETag revalidation, the same-origin redirect rule, discovery's
  SSRF guard tightened rather than left unchanged, and the rate cap
  dropped on purpose;
- branding moved to #103/#200 and keyed on the validated redirect. A
  client_id-domain key would have let any local program pose as a
  loopback CIMD client of a vetted vendor;
- the DoS residuals as built, the tests that pin the behaviour, and an
  index of where it lives.

Section numbers 3.4 and 3.5, which the code cites, keep their meaning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CIMD record: untrusted URLs are refused, eviction is by expiry, withdrawals wait for expiry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CIMD record: log sampling protects the default (info) level, not debug

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Brings the branch up to date with 27 commits on main. Two of them needed
changes on this side:

- #221 validates the PKCE code_challenge at /oauth/authorize, so the
  router test's branding round trip now authorizes with a well-formed S256
  challenge (the conflict in tests/routers.rs).
- #234 reads pending connects on tokio's clock and keeps expired ones
  around so the redeem gate can say "expired". branding_is_bound_to_the_session
  now runs on a paused clock: it walks a connect past CONNECT_TTL and checks
  that an expired but still-retained connect gets no branding, replacing
  the old checked_sub check that some hosts skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 15: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.

🟢 Approval recommended

No active review issues were identified for the session-bound connector branding changes.

No open issues 🎉

Review effort: Balanced

aterga and others added 2 commits November 10, 2026 16:59
oisy.com now serves a real /.well-known/ii-derivation-origin
("https://oisy.com"), so it resolves as Declared: the app's own
declaration wins over the built-in registry, as intended. That broke
resolve_app_identity_skips_probe_for_known_apps, which assumed oisy.com
still served its app shell there, and with it CI for every PR.

multidex.ai is a known app that still answers that path with its app
shell (far over MAX_DERIVATION_ORIGIN_BYTES), so the test keeps pinning
both things it was for: a known app is answered by the registry without
an IC probe, and an over-cap body reads as "declares nothing" rather
than a failed check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- branding_is_bound_to_the_session now checks both sides of the expiry
  boundary on the paused clock: branding answers one second before
  CONNECT_TTL and stops at it. The read-only check compares `created`
  after time has moved, so a lookup that re-stamped the connect would fail.
- Restore the hosted_redirect_allow_list doc comment, which the new
  operator-entries test had been inserted beneath and taken over.
- Cover ChatGPT's Exact-pinned callback (the redirect it actually
  registers) in resolution and vetting, and a descendant that the pin
  refuses.
- Pin that a malformed /branding query (a repeated `state`) gets the same
  no-store 404 as the other misses, not axum's 400.
- The CONNECTORS doc now says why loopback stays anonymous even with a
  CIMD client_id on a vetted origin; correct a stale description of what
  the DCR round-trip test registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 16:02

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.

🟢 Approval recommended

No active review issues are reported for the session-bound connector branding change.

No open issues 🎉

Review effort: Balanced

oisy.com started declaring its own derivation origin, which broke the
live-network test resolve_app_identity_skips_probe_for_known_apps on main
and in every PR's CI. #237 moves the test to multidex.ai. This brings
that one commit in unchanged, so the change collapses into main's copy
once #237 lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 10, 2026 16: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.

🟢 Approval recommended

No active review issues block approval of the session-bound connector branding changes.

No open issues 🎉

Review effort: Balanced

@aterga
aterga merged commit cc843ea into main Nov 10, 2026
17 checks passed
aterga pushed a commit that referenced this pull request Nov 10, 2026
main now carries #237's fix for the stale oisy.com discover test, so the
copy of #238's version this branch carried is dropped in favour of main's
discover.rs, and #200's auth.rs changes merge cleanly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VP2vTJm9ipTPD18xW9HyVG
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