CIMD: allowlist and resolver #152
Labels
No labels
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lz/agent-nexus#152
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Client ID Metadata Documents are how Claude registers itself. The MCP 2026-07-28 spec deprecated DCR in favour of CIMD — "DCR continues to work for backward compatibility, but will be removed in a future version of the MCP spec" — and Claude Code identifies itself with its own CIMD at
https://claude.ai/oauth/claude-code-client-metadata.Depends on #151.
This is the only security-relevant code MCP adds
oidc-providerdoes not implement CIMD, so this piece is ours. Keep it small enough to read in full.The naive implementation is an SSRF primitive. CIMD makes the
client_ida URL the authorization server fetches. Nexus runsnetwork_mode: hostwithDOCKER_HOST=tcp://127.0.0.1:2375— the socket proxy publishes there specifically so host-mode Nexus can reach it (AGENTS.md fact #17), withcontainers,exec,imagesandnetworksallowlisted. "Fetch this URL for me" next to that is a shape worth no convenience.The design removes the primitive rather than mitigating it
client_id.client_idfinds no client and is refused. Fail-closed.So at request time there is no fetch at all, and the fetch target is operator-controlled rather than client-controlled.
Verified as the right shape: Pocket ID does exactly this, and a fresh instance reports
client_id_metadata_document_supported: falseuntil the allowlist is non-empty. Ours must serialize the same way.Fetch constraints
For the one operator-initiated resolution — belt-and-braces given the allowlist, and cheap:
Metadata
The authorization server metadata must advertise both
client_id_metadata_document_supported: trueand"none"intoken_endpoint_auth_methods_supported. Claude selects CIMD only when both are present and silently falls back to DCR otherwise, so a test asserts both against the live document.Watch for
Loopback redirect matching. Claude Code binds an ephemeral port, and Anthropic requires the authorization server accept
http://localhost/callbackandhttp://127.0.0.1/callbackwith the port ignored — RFC 8252 §7.3 mandates this for IP literals only, so thelocalhosthalf is an extra requirement. Pocket ID'sloopbackURLWithWildcardPortis a good reference implementation: it strips the port and re-matches forlocalhostplus anyIsLoopback()IP, with IPv6 bracketing.Never register a client whose redirect URI pattern is
*.Done when
Claude Code completes an authorization flow against a Nexus instance with only its CIMD URL allowlisted; an un-allowlisted URL is refused; the allowlist being empty makes the advertised flag
false; and the SSRF constraints are each a test proven able to fail.Two findings from building #151, both verified against the installed
oidc-provider@9.12.2source. The first confirms this issue's design is the right one; the second is a trap that would otherwise be found the hard way.The design here works, and it works because the library feature stays off
Client.find(id)(lib/models/client.js:572-615) resolves in three steps, in order:staticClients.get(id)— fromConfiguration.clientsawait this.adapter.find(id)— our Kysely adapterfeatures.clientIdMetadataDocument.enabledis true, and the id is a URL →resolveClientByMetadataDocument(...)This issue's plan — resolve the document when the operator adds an allowlist entry, then register a normal client row keyed by that URL as its
client_id— lands that row at step 2. Step 3 never runs. So no library feature needs enabling, and the request-time fetch never exists.Do not enable
features.clientIdMetadataDocumentIt is off by default (
lib/helpers/defaults.js:1498) and it must stay off. Turning it on reintroduces exactly the primitive this issue's design removes: a fetch, at request time, of a URL supplied by the client. Worse, both of its gating hooks default to unconditionally permissive:So enabling it without binding both hooks would let Nexus fetch client metadata from any https URL. Nexus runs
network_mode: hostwithDOCKER_HOST=tcp://127.0.0.1:2375(AGENTS.md fact #17) — that is a server-side request forgery primitive sitting next to the Docker API, which is the shape this issue was written to avoid.The operator-initiated, one-time fetch described above is strictly safer than anything achievable by enabling the feature and hardening its hooks, because the fetch target is operator-controlled rather than client-controlled.
What #151 ships
discovery.client_id_metadata_document_supportedis set fromcimdAllowlist.length > 0, so it serializesfalseon an empty allowlist — matching the Pocket ID behaviour this issue cites. Nothing populates the allowlist yet, so Claude Code cannot complete a flow againstmainuntil this issue lands. That is the intended state for #151 and is called out in its PR.config.tscarries a comment recording all of the above, so the next person to read the discovery flag does not conclude the feature was left off by mistake.One correction to the issue body's "Metadata" section while I am here: it says a test asserts both
client_id_metadata_document_supported: trueand"none"intoken_endpoint_auth_methods_supportedagainst the live document. #151 asserts the"none"half and asserts the CIMD flag isfalse(empty allowlist). Thetruecase belongs here, since this issue is what makes it reachable.Shipped in #169, with the design inverted
The body above specifies a resolver we write: fetch at allowlist time, register a static client row, never enable
features.clientIdMetadataDocument. What shipped is the opposite — the library's own CIMD feature, with both gating hooks bound to the allowlist. Leaving this note so nobody implements the body as written.Why it changed. The issue rejects the library feature because
allowFetch/allowClientdefault permissive next to a host-networked Docker API. That objection is removable by configuration: bound to the allowlist, the fetch target is operator-controlled, which is the same end "resolve at add time" was reaching for. And the library's own guard is stronger than a hand-written one —fetch_request.jscheckssocket.remoteAddresson undici'sconnectevent, i.e. the peer actually connected to, so DNS rebinding has no window to race. An early draft of our plan hand-wrote that guard and would have passed2002:7f00:1::(a 6to4 address embedding 127.0.0.1) as public.What genuinely changed is when the fetch happens. The body says "at request time there is no fetch at all"; there is now one, on a cold cache, against a URL the operator allowlisted. Accepted knowingly — if
claude.aiis unreachable, Claude is not connecting anyway.Corrections to the body's technical claims
"Pocket ID's
loopbackURLWithWildcardPortis a good reference implementation" — not needed.oidc-provideralready shipsstripLoopbackPort, and itsLOOPBACKSset is{localhost, 127.0.0.1, [::1]}, so it already covers thelocalhosthalf the body flags as an Anthropic requirement beyond RFC 8252 §7.3.The real work was elsewhere, and the body doesn't mention it:
#redirectAllowedreturnsfalsebefore stripping the port unlessapplicationType === 'native', and the default isweb. Claude Code's live document declares noapplication_typeand registers portless loopback redirects, so registered as-is the client validates fine and then refuses every callback it can make.clientDefaults.application_type = 'native'is the only lever —resolveClientByMetadataDocumentbuilds the client verbatim andallowClientruns after construction, so neither can transform metadata."a test asserts both
client_id_metadata_document_supported: trueandnone" — done, but notediscovery.jssets that flag whenever the feature is enabled and thedefaults()after it only fills absent keys. So thediscoveryentry inconfig.tsis consulted only in the off state, andenabledis gated on the allowlist being non-empty instead. A fresh instance therefore has no CIMD fetch path in existence at all, not merely an advertisedfalse."an un-allowlisted
client_idis refused" — yes, but via both hooks.allowFetchruns only on a cache miss;allowClientis what refuses a client whose entry was removed while its document still sat in the LRU.Consequences accepted
native_client_prompt) rather than silently reusing a Grant.ttl.RefreshTokenis now ours.application_type: 'native'disables the library's refresh-chain cap, which is gated onapplicationType === 'web'— measured: web inherits the rotated token's remaining life, native gets a fresh 14 days every rotation, i.e. an unbounded chain. The override is the library's logic minus that clause, withttl-parity.test.tspinning the vendor branch.Not done here
The Settings → MCP panel is #153. This shipped the service and
GET/POST/DELETE /api/settings/mcp/clientsonly. Two things #153 inherits:added_atis the only stored metadata (the client name lives in the document, deliberately not copied), and removal stops future authorizations and refreshes immediately but leaves already-issued access tokens valid for up to an hour — true immediate revocation needs aclient_idcolumn onoauth_payloads.The "done when" is not fully met: Claude Code cannot complete a flow against Nexus yet, because
/api/mcpdoes not exist. That is #154. What was verified, against the built server with Claude Code's real document: a full PKCE authorization accepted on a loopback port the document does not register, the discovery flag flipping without a restart, and the consent screen rendering with the client name and loopback warning.