You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Surface vetted-connector branding to Internet Identity, bound to the authorization session - #200
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-inDEFAULT_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.
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>
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>
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
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.
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>
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>
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>
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>
… 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>
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>
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>
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
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
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.
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
src/brandingmodule. It covers ChatGPT, Claude, Cursor, Grok, Perplexity and Google Antigravity.redirect_urivendor, never on the client-suppliedclient_name/logo_uri. Open DCR takes all callers, so client-supplied identity is attacker-controlled.DEFAULT_ALLOWED_REDIRECTSentry is branded (redirect_uri_on_default_allow_list). AnOAUTH_ALLOWED_REDIRECT_PREFIXESentry 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.GET {issuer}/branding?state=<state>→{ name, logo, verified }, CORS-open. Every response, including the404, isno-store.stateit already holds from the connect link.authorizevalidated.404, as does an unknown, missing, expired or malformedstate. They are indistinguishable, so the endpoint is no session oracle.GET {issuer}/branding/{slug}/logo→ SVG withnosniff, a cache header, and a sandboxing CSP (default-src 'none'; style-src 'unsafe-inline'; sandbox). It returns404outside the curated set.<img>and never inline it, because an inlined SVG can run script.host_key+host_is_or_under, so they cannot disagree about a vendor.https://claude.ai./…(accepted by validation) brands as Claude.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:
/branding?state=from the validated callback origin, using the onestateit parsed from the link for both the lookup and the callback;Until that ships, the endpoints are inert.
Tests
http, redirects validation refuses, and operator-added entries on a vendor's domain;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.connectorparam.statefrom II's link →/branding?state=→ 200Claude+no-storefor 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-store; the old unbound/branding/{slug}metadata route is gone; the logo is served as SVG withnosniffand the sandboxing CSP.🤖 Generated with Claude Code