Skip to content

fix: CIMD redirect policy — loopback callbacks proceed, allowed_domains vets remote ones; docs: name both ways to supply the browser leg - #284

Open
rsharath wants to merge 9 commits into
mainfrom
docs/cimd-browser-leg-routes
Open

fix: CIMD redirect policy — loopback callbacks proceed, allowed_domains vets remote ones; docs: name both ways to supply the browser leg#284
rsharath wants to merge 9 commits into
mainfrom
docs/cimd-browser-leg-routes

Conversation

@rsharath

@rsharath rsharath commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #278 (merged), correcting a docs gap it left — and, since #285 landed mid-review, fixing a promise that PR's docs make and its code does not keep.

1. Two ways to supply the browser leg (the original change)

docs/cimd.md said the browser leg "needs a GET-capable PrincipalResolver, which ZeroID does not ship" and that the deployer "must register one that reads a session cookie." True of one shape and not the only one, and it describes the opposite of what Highflame actually runs.

There are two routes, and the second needs no GET-capable resolver at all:

  1. Register a cookie-reading resolver and own the login and consent screens behind it. /oauth2/authorize becomes the browser-facing endpoint, with the CSRF obligations that brings.
  2. Front the browser leg above ZeroID and hand off over POST. Your surface owns the redirect, authenticates the human, and POSTs an RFC 7523 assertion a form-based resolver reads. The browser never reaches the endpoint.

Route 2 does not remove authorization-request CSRF — it moves it to the fronting surface, which is where the explicit consent gesture has to happen. Documented as such rather than left implied.

Highflame takes route 2 (Studio authenticates, mints, POSTs). Route 1 exists for deployers with no such surface. The SetAuthorizationCodeAvailable guidance is corrected to match: a route-2 deployment is fine with only form-based resolvers.

2. cimd.allowed_domains now actually restores redirects

This is a behaviour change, and the reason this PR is no longer docs-only.

#285 added the §4.1.2.1 self-asserted carve-out: a CIMD client gets a §5.2 JSON body instead of an error redirect, and redirectToInteractiveLogin refuses it too. It documented an escape hatch in three places — failAuthorize's godoc, redirectToInteractiveLogin's godoc, and docs/cimd.md §12.5 — all saying that setting cimd.allowed_domains restores the redirect, because vetting which hosts may publish restores the assumption the rule is built on.

No code implemented it. Both gates read client.SelfAsserted() alone, which is RegistrationSource == "cimd", set unconditionally at synthesis (internal/service/cimd.go:548) whether or not an allow-list is in force. The handler never received the allow-list at all — API carried only cimdEnabled. Setting the config did nothing.

The fix is small and deliberately shaped so it cannot rot again:

  • API.cimdPublishersVetted, set by Server.NewServer from cimdSvc.AllowedDomainCount() > 0 — the effective count, for the same reason the startup log uses it: allowed_domains: [""] has length 1 and vets nothing.
  • One predicate, refusesRedirectTo, behind both gates. They were two copies of the same condition; now they are one, so the error redirect and the login redirect cannot drift apart.
  • The empty-allowlist startup warning names the new consequence: without a list, a browser-driven CIMD client cannot sign a user in.

3. A loopback callback is redirected to — the carve-out is about reach

The carve-out refuses a self-asserted client because its redirect_uris are attacker-chosen, making the endpoint "an unauthenticated redirector with the AS's own origin as the first hop." That is a claim about a remote destination. It was being applied to every destination.

A 302 to 127.0.0.1 has no remote hop. The code lands on the machine the user is sitting at, and an attacker able to listen there already has local code execution — the same reasoning RFC 8252 §7.3 uses to accept loopback callbacks from clients nobody registered.

This is not a corner case, it is the MCP case. A CIMD client_id names the client vendor's domain, and the canonical document at the top of docs/cimd.md — an ordinary desktop/CLI MCP client — lists loopback callbacks and nothing else. The carve-out was costing the whole browser leg for the dominant client shape while preventing nothing, and allowed_domains was being asked to buy back something loopback already gives for free.

refusesRedirectTo now asks whether the destination can reach a third party the client chose:

Destination Redirected?
loopback http://, private-use scheme yes — delivers to the caller's own device
remote https://, self-asserted, unvetted host no
remote https://, self-asserted, allow-listed host yes (§2)
registered / RFC 7591 client yes, unchanged

service.RedirectDeliversLocally holds the judgement, next to the URI rules it belongs with. Loopback matching is exact, so 127.0.0.1.evil.com is remote — tested.

Consequence: a desktop MCP client completes the browser leg with no allowlist configured at all. Only clients with real https:// callbacks need cimd.allowed_domains, and the config godoc, zeroid.yaml, docs/cimd.md and spec §12.5 now say that rather than the blanket MUST an earlier revision of this PR carried.

Why this matters beyond conformance

It is the difference between MCP CIMD working end to end and not working at all.

An MCP client sends the user's browser to GET /oauth2/authorize with an https:// client_id. Discovery, document resolution, redirect_uri validation and code redemption all work. Principal resolution is the whole gap: a browser presents no credential, so the resolver chain must be able to say "send this person to log in." For a CIMD client that redirect was refused unconditionally — meaning a user with no existing session could never establish one, and the flow could not complete on any deployment, at any configuration. The allow-list was described as the switch and was wired to nothing.

§3 then removes the need for that switch entirely for native clients, which is where MCP actually lives. §2 remains what makes the allow-list trustworthy for the hosted-client case.

allowed_domains remains deployment-wide, so this re-enablement is meaningful only on a single-tenant deployment — unchanged from #285, still tracked in #286.

Tests

TestVettedPublishers_RestoresRedirects covers both halves — the error redirect (status, error, state echoed) and the interactive-login redirect — plus that vetting does not bypass the nil-client or non-GET refusals, so "vetted" never becomes a blanket exemption. #285's existing TestSelfAsserted_* tests pin the default-off behaviour and still pass unchanged.

Docs and spec §12.5 updated to describe the refusal as conditional, which is what it now is.

…esolver

docs/cimd.md said the browser leg "needs a GET-capable PrincipalResolver, which
ZeroID does not ship" and that the deployer "must register one that reads a
session cookie". True as far as it goes, but it presents the harder route as the
only route — and it is not the route Highflame itself takes.

A deployer can instead front the browser leg ABOVE ZeroID: own the redirect,
authenticate the human however they already do, then POST to /oauth2/authorize
with a credential a form-based resolver reads — an RFC 7523 assertion signed by
that surface, verified against its published JWKS. The browser never reaches the
endpoint, so no GET-capable resolver is needed AND the CSRF exposure documented
below does not arise: the caller is a server, not a navigation.

That matters because the CSRF obligations are the expensive part of route 1, and
a reader who thinks route 1 is mandatory takes them on unnecessarily. Highflame's
own deployment is route 2 — Studio authenticates, mints an assertion and POSTs;
AuthN's assertion resolver verifies it and ZeroID mints the code.

Either way ZeroID stays the engine: it validates the CIMD document, enforces the
redirect_uri allow-list, and issues the code. Route 1 is for deployers with no
such surface of their own.

Docs only.

Co-Authored-By: Claude Opus 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.

Pull request overview

Documents two ways to connect CIMD’s browser authorization leg to ZeroID.

Changes:

  • Describes cookie-based GET resolution.
  • Adds a fronted server-to-server POST pattern using signed assertions.
  • Documents Highflame’s deployment approach.
Suppressed comments (1)

docs/cimd.md:82

  • This form-based resolver route conflicts with the public API documentation: RegisterPrincipalResolver tells every form-only deployment to disable the grant (server.go:759-765), and SetAuthorizationCodeAvailable repeats that instruction (server.go:905-915). Following that guidance makes this POST handoff return 503. Update those Go docs to distinguish direct browser GETs from a fronted server POST so deployers do not receive opposite instructions.
   and then POSTs to `/oauth2/authorize` with a credential a form-based resolver
   reads — an RFC 7523 assertion signed by that surface, say, verified against

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

Comment thread docs/cimd.md Outdated
Comment thread docs/cimd.md Outdated
rsharath and others added 5 commits August 11, 2026 18:31
…-resolver premise

Review follow-up (PR #284), both threads:

- The bold premise said the browser leg needs a GET-capable resolver,
  full stop, while route 2 two paragraphs later needs none. It now says
  DIRECT browser access at /oauth2/authorize needs one, and the list is
  framed as the two ways to connect the browser leg. Same qualification
  on the 'ZeroID cannot detect this' paragraph: form-based-only is a
  misconfiguration only when no fronting surface exists.

- Route 2 no longer claims the CSRF exposure 'does not arise'. Moving
  the final hop to a server-to-server POST removes navigation
  reachability of /oauth2/authorize itself, but an attacker can still
  navigate a victim to the fronting surface with an attacker-published
  client_id — so the CSRF-protected consent interaction must happen at
  that surface before the assertion is minted. Said so, explicitly.

Also scopes the 'Highflame takes route 2' claim: MCP clients still go
through Studio's local code-minting today; highflame-studio#1392 brings
them onto this path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#285 landed ErrPrincipalInteractionRequired + SetInteractiveLoginURL, so a
cookie resolver no longer has to hand-roll the 302 to its login screen. It
also refuses that redirect for a self-asserted (CIMD) client, which is the
half that matters in this doc: on route 1 a CIMD authorization request only
succeeds for a user who already has a session.
@rsharath rsharath changed the title docs: name both ways to supply the CIMD browser leg fix: cimd.allowed_domains restores redirects to a CIMD client; docs: name both ways to supply the browser leg Aug 21, 2026
#285 shipped the §4.1.2.1 self-asserted carve-out with an escape hatch that
three places document and no code implements. failAuthorize and
redirectToInteractiveLogin both gated on client.SelfAsserted() alone, and
RegistrationSource is set to "cimd" unconditionally at synthesis, so setting
cimd.allowed_domains changed nothing. The handler never even received the
allow-list — API carried only cimdEnabled.

Both gates now go through one predicate, refusesRedirectTo, so the error
redirect and the interactive-login redirect cannot drift apart: they answer
the same question about the same client. Server.NewServer feeds it
AllowedDomainCount() > 0 — the EFFECTIVE list, for the same reason the
startup log uses it, since allowed_domains: [""] has length 1 and vets
nothing.

This is what makes the browser leg completable for an MCP CIMD client. An
unvetted one is never sent to the login surface, so a user with no session
cannot establish one and the flow cannot finish at all — the allow-list is
the switch, and it was wired to nothing. The empty-allowlist startup warning
now names that consequence too.

Also ignores .gstack/, which is per-session browser audit output.
@rsharath
rsharath force-pushed the docs/cimd-browser-leg-routes branch from 50a40b5 to dfafe06 Compare August 21, 2026 05:51
…publishers

Review of the previous commit found the premise it rests on is not actually
established. refusesRedirectTo reads "an allow-listed publisher is a vetted
party, so redirects apply again" — but nothing tied redirect_uris to the
allow-list, or even to the client_id host. synthesizeCIMDClient checks scheme
rules only.

So on any host where more than one party can publish a path — user content, a
raw-file CDN, a broadly writable bucket, a shared internal app host, the
config's own apps.acme.dev example — allow-listing it re-opened exactly what
#285 closed: publish a document there naming redirect_uri
https://evil.example/cb, and an unauthenticated GET /oauth2/authorize 302s to
evil.example. Worse than before the allow-list, in fact, because
redirectToInteractiveLogin now walks a victim through the real login page
first, so the code lands at the attacker after a genuine sign-in.

redirectHostsAllowed closes it: an https redirect_uri must be on the
client_id's own host or on the allow-list. Loopback and private-use schemes
stay exempt — they deliver to the caller's own machine, which is the native
and MCP client shape. In open mode domainAllowed admits everything, so this
is a no-op there, correctly: open mode refuses those redirects outright.

Also from review:

- refusesRedirectTo refused to answer for a nil client by returning false.
  Folded the nil case in and dropped the duplicated check at both call sites,
  which is what "one predicate so they cannot drift" was supposed to mean.
- CIMDConfig.AllowedDomains' godoc and zeroid.yaml still described the field
  as a fetch/SSRF lever with empty as a fine default. It now also decides
  whether a browser CIMD client can sign a user in at all, and both say so —
  along with the new obligation that listing a host asserts you vet who
  publishes there.
- docs/cimd.md's "Errors are not redirected to a CIMD client" heading and a
  spec cross-reference pointing at §12.6 (Caching) instead of §12.7.
…ut reach

The §4.1.2.1 carve-out refuses to redirect to a self-asserted client because
its redirect_uris are attacker-CHOSEN, which would make the endpoint "an
unauthenticated redirector with the AS's own origin as the first hop." That
is a claim about a REMOTE destination, and it was being applied to every
destination.

A 302 to 127.0.0.1 has no remote hop. The code lands on the machine the user
is sitting at, and an attacker who can listen there already has local code
execution — the same reasoning RFC 8252 §7.3 uses to accept loopback
callbacks from clients nobody registered. CIMD does not weaken it.

This is not a corner case, it is the MCP case. A CIMD client_id names the
client VENDOR's domain, and the canonical document in docs/cimd.md — an
ordinary desktop/CLI MCP client — lists loopback callbacks and nothing else.
So the carve-out was costing the entire browser leg for the dominant client
shape while preventing nothing, and cimd.allowed_domains was being asked to
buy back something the loopback property already gives for free.

refusesRedirectTo now asks whether the destination can reach a third party
the client chose: local delivery proceeds, remote https stays subject to
provenance and the allow-list. service.RedirectDeliversLocally holds the
judgement, next to the URI rules it belongs with; exact-match loopback means
127.0.0.1.evil.com is remote, which is tested.

Consequence worth stating: a desktop MCP client now completes the browser leg
with no allowlist configured at all. Only clients with real https callbacks
need cimd.allowed_domains, and the config surface, docs and spec §12.5 say so
rather than the blanket MUST they carried an hour ago.
@rsharath rsharath changed the title fix: cimd.allowed_domains restores redirects to a CIMD client; docs: name both ways to supply the browser leg fix: CIMD redirect policy — loopback callbacks proceed, allowed_domains vets remote ones; docs: name both ways to supply the browser leg Aug 21, 2026
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