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
Conversation
…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>
There was a problem hiding this comment.
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:
RegisterPrincipalResolvertells every form-only deployment to disable the grant (server.go:759-765), andSetAuthorizationCodeAvailablerepeats 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.
…-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.
akhiljavelin
approved these changes
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
force-pushed
the
docs/cimd-browser-leg-routes
branch
from
August 21, 2026 05:51
50a40b5 to
dfafe06
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdsaid the browser leg "needs a GET-capablePrincipalResolver, 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:
/oauth2/authorizebecomes the browser-facing endpoint, with the CSRF obligations that brings.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
SetAuthorizationCodeAvailableguidance is corrected to match: a route-2 deployment is fine with only form-based resolvers.2.
cimd.allowed_domainsnow actually restores redirectsThis 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
redirectToInteractiveLoginrefuses it too. It documented an escape hatch in three places —failAuthorize's godoc,redirectToInteractiveLogin's godoc, anddocs/cimd.md§12.5 — all saying that settingcimd.allowed_domainsrestores 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 isRegistrationSource == "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 —APIcarried onlycimdEnabled. Setting the config did nothing.The fix is small and deliberately shaped so it cannot rot again:
API.cimdPublishersVetted, set byServer.NewServerfromcimdSvc.AllowedDomainCount() > 0— the effective count, for the same reason the startup log uses it:allowed_domains: [""]has length 1 and vets nothing.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.3. A loopback callback is redirected to — the carve-out is about reach
The carve-out refuses a self-asserted client because its
redirect_urisare 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.1has 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_idnames the client vendor's domain, and the canonical document at the top ofdocs/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, andallowed_domainswas being asked to buy back something loopback already gives for free.refusesRedirectTonow asks whether the destination can reach a third party the client chose:http://, private-use schemehttps://, self-asserted, unvetted hosthttps://, self-asserted, allow-listed hostservice.RedirectDeliversLocallyholds the judgement, next to the URI rules it belongs with. Loopback matching is exact, so127.0.0.1.evil.comis remote — tested.Consequence: a desktop MCP client completes the browser leg with no allowlist configured at all. Only clients with real
https://callbacks needcimd.allowed_domains, and the config godoc,zeroid.yaml,docs/cimd.mdand 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/authorizewith anhttps://client_id. Discovery, document resolution,redirect_urivalidation 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_domainsremains deployment-wide, so this re-enablement is meaningful only on a single-tenant deployment — unchanged from #285, still tracked in #286.Tests
TestVettedPublishers_RestoresRedirectscovers both halves — the error redirect (status,error,stateechoed) 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 existingTestSelfAsserted_*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.