feat: Client ID Metadata Documents with SSRF-guarded resolution + consent interstitial (#166)#167
feat: Client ID Metadata Documents with SSRF-guarded resolution + consent interstitial (#166)#167heskew wants to merge 6 commits into
Conversation
…n and consent interstitial (#166) Implements the MCP authorization spec's CIMD support: when a client_id is an HTTPS URL with a non-root path, the AS fetches it as a JSON metadata document instead of doing a DCR lookup. Core changes: - New `src/lib/mcp/cimd.ts`: `isCimdClientId`, `resolveCimdClient`, `resolveClient`. SSRF guards via DNS pre-flight (all A/AAAA records checked against private/loopback ranges), IP-literal rejection, no-redirect fetch, 5 s timeout, 64 KB cap. Per-process cache with `Cache-Control: max-age`-based TTL clamped to [60 s, 86400 s]; negative-cache on client errors (60 s), not on server errors. - New `src/lib/mcp/clientValidator.ts`: shared validators extracted from dcr.ts (`validateRedirectUri`, `validateStringArray`, `validateGrantTypes`, etc.). - `src/lib/mcp/authorize.ts`: `resolveClient` replaces direct `MCPClientStore` lookup. `escapeHtml` and `buildInterstitialPage` for the CIMD consent page. `handleAuthorize` returns 200 HTML for CIMD clients; 302 for stored/DCR clients. New `handleAuthorizeConfirm` for `POST /oauth/mcp/confirm`: verify + consume one-time token, validate `_confirm` marker, redirect to upstream IdP. - `src/lib/mcp/wellKnown.ts`: advertises `client_id_metadata_document_supported: true` when CIMD is enabled (default on). - `src/lib/mcp/token.ts`: `authenticateClient` uses `resolveClient`; handles `CimdClientError` as `invalid_client`. - `src/lib/mcp/index.ts` + `src/lib/resource.ts`: route `POST /oauth/mcp/confirm` to `handleAuthorizeConfirm`; thread `providers` registry into `handleMCPPost`. - `src/types.ts`: `MCPClientIdMetadataDocumentsConfig`, `MCPConfig.clientIdMetadataDocuments`, `MCPClientRecord._cimd`, `MCPClientMetadata.jwks/jwks_uri`. Tests (50 new passing): - `test/lib/mcp/cimd.test.js`: isCimdClientId shape checks, SSRF DNS gate, allowedHosts policy, document validation, cache TTL, resolveClient routing. - `test/lib/mcp/authorize.test.js`: escapeHtml (XSS vectors), buildInterstitialPage (loopback warning, token binding, XSS), handleAuthorize CIMD path (200 HTML), handleAuthorizeConfirm (valid token → 302, single-use, expired, missing _confirm). - `test/lib/mcp/wellKnown.test.js`: CIMD flag present when enabled, absent when disabled. Docs: CIMD section in mcp-oauth.md, four config rows in configuration.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
clientIdMetadataDocuments.allowedHosts governs which hosts may SERVE metadata documents; redirect URIs are a different policy and now validate against dynamicClientRegistration.allowedRedirectUriHosts (same rules as DCR clients). A trusted vendor whose document declares redirect targets on another host is no longer wrongly rejected. Regression tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
1 blocker found. 1. Spec violation:
|
This comment has been minimized.
This comment has been minimized.
|
Reviewed; no blockers found. |
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request implements Client ID Metadata Document (CIMD) resolution for the Model Context Protocol (MCP) authorization flow, allowing clients to identify themselves using HTTPS URLs. It introduces a DNS-gated fetch mechanism with SSRF guards, process-level caching, and an interstitial confirmation page requiring explicit user confirmation before redirecting to the upstream IdP. The review feedback focuses on strengthening the SSRF filters for both IPv4 and IPv6 addresses, respecting 'no-store'/'no-cache' directives in Cache-Control headers, defensively handling potential runtime exceptions from malformed redirect URIs, and improving error observability by logging caught exceptions and preserving original error stacks using the 'cause' option.
heskew
left a comment
There was a problem hiding this comment.
Reviewed the security-critical surfaces (cimd.ts SSRF gate, the interstitial in authorize.ts, resolveClient wiring in token.ts, shared clientValidator). Two blockers, both in the areas this PR flagged for review. Neither is a design problem — both are contained fixes.
Blockers
1. SSRF DNS gate misses 0.0.0.0 / 0.0.0.0/8 and IPv6 :: — src/lib/mcp/cimd.ts (isPrivateIpv4 / isPrivateIpv6, ~L111–137)
isPrivateIpv4('0.0.0.0') returns false (a=0 matches none of the ranges), and 0.0.0.0 routes to loopback on Linux. Because the client controls the DNS record for their client_id URL host, pointing it at 0.0.0.0 passes checkHostSsrf and the subsequent fetch connects to the AS's own localhost services — a reachable SSRF-to-loopback bypass of the primary containment. IPv6 :: (unspecified) slips through the same way (firstGroup is empty → returns false).
Fix: reject a === 0 (0.0.0.0/8) in isPrivateIpv4, add 100.64.0.0/10 (CGNAT) while there, and reject the all-zeros :: in isPrivateIpv6. Add 0.0.0.0 and :: cases to cimd.test.js.
2. Consent interstitial is frameable (clickjacking) — src/lib/mcp/authorize.ts (HtmlResponse, ~L450–454)
The interstitial exists specifically to satisfy the spec's "clearly display the redirect URI hostname" consent requirement, but it's served with only Content-Type. A malicious CIMD client can frame it and clickjack the "Continue to sign in" POST for a victim already authenticated upstream, defeating the consent gate.
Fix: add X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' (and Cache-Control: no-store, since the body carries the single-use confirm token) to the response headers.
Held up under scrutiny
DNS-rebinding TOCTOU is disclosed and accepted; the lying-content-length case is covered by the streaming byte cap; redirect: 'error' closes redirect-SSRF; escapeHtml covers every client-controlled interpolation; the shared clientValidator holds CIMD and DCR to identical redirect/grant rules; and the #159 handoff (jwks carried through, private_key_jwt gated off in v1) is wired correctly.
Flagging with extra weight because #161/#162 (the client_credentials grant for headless agents) now resolve clients through resolveClient() and will lean on this exact SSRF gate to fetch agent JWKS documents — the 0.0.0.0 gap should close before that builds on top.
🤖 Review by Claude (Fable 5)
heskew
left a comment
There was a problem hiding this comment.
Fresh pass on f0da8a1. I treated the existing SSRF range, bounded-cache, no-store/no-cache, error-observability, and clickjacking comments as already raised. I found three additional issues:
-
src/lib/mcp/cimd.ts:371accepts non-2xx metadata responses. The resolver never checksresponse.ok/status, so a404or500withContent-Type: application/jsonand a syntactically valid CIMD body is accepted and cached as a client. The current CIMD draft requires the document to be served with200 OKand treats other status codes as discovery errors. Please reject anything other than200before content-type/body parsing, and add a regression test for404/500JSON. -
src/lib/mcp/cimd.ts:429negative-caches invalid/error metadata documents. That is separate from the already-raised “Map is unbounded” issue: the current CIMD draft says the AS MUST NOT cache error responses or invalid/malformed documents. Today a bad document, unsupported auth method, oversized body, or non-JSON response is cached for 60s, and the test suite codifies that behavior. Please remove negative caching for CIMD discovery failures, or at least do not cache the classes the draft forbids. -
src/lib/mcp/cimd.ts:354usesfetchTimeoutMsandmaxDocumentBytesdirectly from config.maxDocumentBytes: NaNorInfinitydisables both size checks (contentLength > maxBytesandtotal > maxBytesare false), so the 64 KB cap fails open. This config path also supports env-expanded values, and nearby MCP config already coerces TTL strings defensively. Please coerce/validate these options to finite positive numbers before use, falling back to defaults or failing closed, and coverNaN/Infinity/numeric strings in tests.
Verified locally: npm run build; node --import ./test/helpers/harper-mock.mjs --test test/lib/mcp/cimd.test.js test/lib/mcp/authorize.test.js test/lib/mcp/wellKnown.test.js test/lib/mcp/token.test.js.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
heskew
left a comment
There was a problem hiding this comment.
Fresh security pass on f0da8a1. I treated the already-raised SSRF-range, cache-bound, clickjacking/cache-control, non-200, negative-cache, and numeric-config findings as open and did not repeat them. I found the following additional issues.
-
Blocker: the CIMD consent is not bound to the resource owner's browser -
src/lib/mcp/authorize.ts:432-525/authorizereturns a bearerconfirm_token, and/confirmvalidates only that token. A malicious CIMD client can retrieve and submit the interstitial itself, then send the resulting upstream IdP URL to a victim. The victim never sees the client identity or redirect-host disclosure, but an existing IdP session can still produce an authorization code at the attacker's redirect URI, with the attacker's PKCE verifier. This defeats the purpose of the interstitial.Bind the confirmation and subsequent upstream callback to a Secure, HttpOnly, SameSite browser nonce, or authenticate upstream first and render consent afterward. The callback must verify that same binding.
-
Blocker: a confirm token is accepted as upstream OAuth callback state -
src/lib/mcp/authorize.ts:434,src/lib/handlers.ts:176-265Confirm tokens use the shared CSRF store and carry both
mcpand_confirm. The callback treats any verified state carryingmcpas upstream state and never rejects_confirm. Supplying a confirm token as the upstreamstatetherefore exchanges the upstream code, persists an MCP authorization code, and redirects it to the malicious client without calling/confirm.Add an enforced token purpose, for example
cimd_confirmversusupstream_oauth, and reject each purpose on the wrong endpoint. Separate stores would be stronger. Add a callback regression test using a_confirmtoken. -
High:
fetchTimeoutMsends at response headers, not document completion -src/lib/mcp/cimd.ts:354-406The abort timer is cleared as soon as
fetch()returns a response. DNS resolution and the streamed body are outside that deadline, so a hostile CIMD server can send JSON headers and never finish the body, tying up unauthenticated requests and outbound connections indefinitely.Apply one deadline across DNS, connection, headers, and body parsing; keep the abort active through body consumption and cancel the reader on expiry.
-
Medium: cached clients survive a live redirect-host-policy tightening -
src/lib/mcp/cimd.ts:338-344Positive cache entries are keyed only by
client_id, even though validation depends on the livedynamicClientRegistration.allowedRedirectUriHostssetting. A redirect accepted before an operator restricts that setting remains accepted for the cached document lifetime, up to 24 hours.Revalidate cached redirects on every hit, include a policy fingerprint in the cache key, or clear CIMD cache entries when relevant MCP configuration changes.
-
Medium: the interstitial omits the authoritative client-ID hostname -
src/lib/mcp/authorize.ts:218-229The page displays the redirect hostname and an optional attacker-controlled
client_uri, but never displays the hostname ofclient_id, which is the domain that served the metadata document. A client can claim a trustedclient_uriwhile using its own client-ID domain. The CIMD phishing guidance recommends displaying the client-ID hostname.Prominently render the escaped hostname from
client.client_id; either omitclient_urior label it as unverified metadata. -
Medium: discovery errors expose Harper's internal DNS view -
src/lib/mcp/cimd.ts:160-164,src/lib/mcp/authorize.ts:352-356,src/lib/mcp/token.ts:129-131A private DNS result is embedded in
CimdClientErrorand returned to unauthenticated callers. Requests for guessed internal names can reveal whether Harper resolves them and disclose the exact private address.Return a generic
invalid_clientdescription and log the hostname/address only server-side. -
Low: dot-segment client IDs are accepted after URL normalization -
src/lib/mcp/cimd.ts:182-195WHATWG URL parsing normalizes both literal and percent-encoded dot segments before this function validates the path. Inputs such as
https://example.com/a/../client.jsonare accepted even though the CIMD draft prohibits dot path components, weakening simple-string client identity.Reject raw and percent-encoded single/double-dot path components before URL normalization, with regression coverage.
Verified locally: 202 targeted authorize, CIMD, callback, token, and discovery tests pass on this head.
…, token purpose, SSRF gate, cache and fetch hardening Consent flow (the two flow-level blockers): - Bind the CIMD consent to the approving browser: the interstitial sets an HttpOnly/Secure/SameSite=Lax nonce cookie whose SHA-256 travels inside the confirm token and upstream state; POST /oauth/mcp/confirm and the OAuth callback both require a hash match before proceeding (new src/lib/mcp/consentBinding.ts). A malicious client can no longer self-approve the interstitial and hand the victim the upstream IdP URL. - Enforce token purpose at the callback: a confirm token presented as upstream OAuth state is rejected like an invalid token (it previously passed the mcp/providerName checks and skipped consent entirely). SSRF DNS gate: - IPv4: also reject 0/8, 100.64/10 (CGNAT), 198.18/15, 224/4+; malformed input fails closed. - IPv6: real parser (:: compression, embedded dotted-quad, zone index); allow only global unicast 2000::/3, with v4-mapped addresses classified by their embedded IPv4 address. Closes the ::, non-canonical loopback, and hex-form v4-mapped bypasses. - All gate rejections (didn't resolve / blocked address) return one generic invalid_client message; details are logged server-side only, so callers can't probe the server's internal DNS view. - Reject dot path segments (raw or percent-encoded) and non-lowercase scheme spellings in CIMD client_ids before URL normalization erases them. Fetch path: - Only 200 OK is accepted (404/500 JSON no longer resolves as a client). - One deadline across DNS, connect, headers, and body read — a trickling body can no longer hold connections open past the timeout. - fetchTimeoutMs/maxDocumentBytes are coerced to finite positive numbers; NaN/Infinity/garbage fall back to defaults instead of failing open. Cache: - LRU-bounded to 1000 entries (keys are attacker-chosen input). - Failures are never cached (CIMD draft forbids caching errors/invalid documents); negative caching removed. - Cache-Control no-store/no-cache floor at the 60 s DoS floor. - Cached records revalidate against the live allowedRedirectUriHosts on every hit, so tightening the policy takes effect immediately. Interstitial: - Served with X-Frame-Options: DENY, CSP frame-ancestors 'none', and Cache-Control: no-store (page carries the single-use confirm token). - Displays the authoritative client_id hostname; client_uri is labelled unverified; unparseable redirect_uri degrades instead of throwing. Plus error-cause chaining and server-side logging on swallowed catch paths (confirm verify, token client lookup). 52 new/updated tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All findings from the three review passes on Claude review (2 blockers)
Codex review (3 findings)
Security pass (7 findings)
Also folded in the gemini bot rounds (all inline threads replied + resolved): bounded LRU cache (1000 entries), Docs ( One deliberate deviation to sanity-check: DNS resolution failure is now reported as 400 🤖 Response by Claude (Fable 5) on Nathan's behalf |
…known DNS family Follow-up from the cross-model review pass on 195cf21. - isPrivateIpv6 now decodes the IPv4 embedded in 6to4 (2002::/16) and ISATAP and classifies it via isPrivateIpv4, and rejects Teredo (2001:0000::/32) outright. These transition forms sit inside the 2000::/3 global-unicast allow but can target a private IPv4 — previously a 6to4/ISATAP address wrapping 10/8 or 127/8 was allowed. - checkHostSsrf fails closed on any DNS address family other than 4/6 rather than skipping both range checks (defense-in-depth; dns.lookup only returns 4/6 today). - Tests for 6to4/ISATAP/Teredo private targets, a public 6to4 pass-through, and the unknown-family reject. In-flight fetch dedup (thundering herd on an uncached attacker URL) was raised as a suggestion and is deferred to rate limiting (#163), bounded meanwhile by the 64 KB / 5 s caps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
heskew
left a comment
There was a problem hiding this comment.
Security review — request changes
Reviewed current head 6fb7dc84 with two independent passes and targeted validation. I found three high-severity issues and four medium-severity issues. The full suite passes locally (926 tests, 924 pass, 2 existing skips), but these need resolution before merge.
1. Sibling-domain cookie injection bypasses CIMD consent — High
File: src/lib/mcp/consentBinding.ts:28
mcp_consent is a normal cookie name. If the AS is auth.example.com, an attacker controlling evil.example.com can fetch its own interstitial server-side to obtain a nonce and confirm_token, set mcp_consent=<nonce>; Domain=example.com in the victim's browser, and auto-submit the confirm form. Since sibling origins are same-site, SameSite=Lax does not block this. Both /confirm and the callback accept the planted cookie, so the victim can complete upstream login and the attacker receives a code at its redirect URI with the attacker's PKCE verifier, without the victim seeing the interstitial.
Rename this to a per-flow __Host-... cookie (the existing Secure; Path=/ and no Domain are compatible), and bind the flow ID in state. Also require the configured issuer as the Origin on /oauth/mcp/confirm as defense in depth. __Host- prevents a sibling from planting a parent-domain cookie; see RFC6265bis §4.1.3.2.
2. DNS timeout does not terminate dns.lookup() work — High
File: src/lib/mcp/cimd.ts:351
withAbort() only rejects the caller; it cannot cancel the underlying dns.lookup(). An attacker can send many client IDs under DNS zones that drop queries. Each request returns after five seconds, but the synchronous getaddrinfo() calls keep occupying libuv's fixed thread pool, starving unrelated DNS/filesystem work. Node documents this exact dns.lookup() behavior in its DNS implementation notes.
Use cancellable DNS resolution and a global in-flight CIMD resolution bound. Do not release a concurrency permit until the underlying lookup has actually stopped.
3. The SSRF preflight is not connection-bound — High
File: src/lib/mcp/cimd.ts:542
checkHostSsrf() validates one DNS answer, then fetch(clientId) resolves the hostname again. An attacker-controlled authority can return a public address to the preflight and an internal/special-use address to the actual connection. TLS narrows the exploitable targets but does not eliminate internal TLS probing or services with compatible SNI/certificates. The PR documents this residual risk, but the current CIMD draft requires authorization servers not to fetch URLs that resolve to special-use addresses.
Pin the HTTP connection to the validated address using a custom lookup/agent while preserving the original hostname for Host, SNI, and certificate verification. Revalidate every retry address. CIMD draft §8.6
4. The IP filter permits RFC 6890 special-use space — Medium
File: src/lib/mcp/cimd.ts:127
The filter allows 192.0.0.0/24, TEST-NET ranges, 2001:2::/48, 2001:db8::/32, and other special-use prefixes. It also intentionally allows public-target 2002::/16 addresses, although 6to4 is special-use. These answers reach _fetch(); I reproduced this with 192.0.2.1.
Classify against the complete IANA IPv4 and IPv6 special-purpose registries and add table-driven tests for every denied prefix. IANA IPv4 registry, IANA IPv6 registry
5. Browser binding runs after upstream login side effects — Medium
File: src/lib/handlers.ts:241
On the self-approved-interstitial path, the cookie mismatch is checked only at line 283. Before that, the callback exchanges the upstream code, loads identity data, and invokes onLogin, which can provision users, synchronize roles, or cause external mutations. The fix prevents MCP code issuance but not attacker-initiated hook effects.
Validate browserNonceHash immediately after state-purpose and provider validation, before upstream errors, code exchange, userinfo, or hooks. Add a regression that a mismatch invokes neither exchangeCodeForToken nor onLogin.
6. CIMD security configuration can fail open — Medium
Files: src/lib/mcp/cimd.ts:485, src/lib/mcp/cimd.ts:649
Environment expansion preserves strings, so enabled: ${CIMD_ENABLED} with CIMD_ENABLED=false becomes "false"; enabled !== false still enables CIMD. A scalar allowedHosts becomes a string and uses substring matching, and an empty list skips the restriction completely.
Normalize/validate configuration at load time: explicitly coerce documented boolean strings, require an array of normalized exact hostnames, and reject invalid or ambiguous security-policy values instead of treating them as unrestricted.
7. One global consent cookie breaks concurrent CIMD flows — Medium
File: src/lib/mcp/consentBinding.ts:28
Every interstitial overwrites mcp_consent. Opening a second authorization flow causes the first to fail either at /confirm or after upstream authentication. This occurs with ordinary parallel tabs and permits deliberate disruption of a pending authorization.
Use bounded per-flow __Host- cookies keyed by a random flow identifier carried in confirmation and upstream state, and clear only the completed flow.
…flow consent cookie, DNS bounding, config hardening Second external-review batch on top of the consent-binding/SSRF work. Consent cookie (Codex #1 sibling-injection High, #7 concurrent-flows Medium): - Switch the consent cookie to a per-flow __Host--prefixed name (__Host-mcp_consent_<flowId>). __Host- forbids a Domain attribute, so a sibling origin can no longer plant a parent-domain cookie to forge the binding (SameSite=Lax doesn't stop siblings — they're same-site). The per-flow id (carried in confirm + upstream state) lets parallel tabs run concurrent flows without clobbering each other's binding. Callback ordering (Codex #5 Medium): - Verify the browser binding BEFORE exchangeCodeForToken and the onLogin hook, so a mismatched (self-approved) flow triggers no upstream exchange and no provisioning side-effects. Regression asserts neither runs on mismatch. SSRF (Codex #3 rebind High, #2 threadpool High, #4 special-use Medium): - Pinned-connect: fetch via https.request with a custom lookup that connects to the exact address the gate validated, while keeping the hostname for TLS SNI + cert verification — closes the DNS-rebinding TOCTOU. (No undici dep: undici isn't importable here; https.request also gives no-redirect-follow for free.) - Bound concurrent DNS resolutions with a permit released only when the raw (uncancellable) getaddrinfo settles, so a flood of blackholed-DNS client_ids can't pin the libuv pool; fast-reject when saturated; dedup concurrent resolutions of the same client_id into one fetch. - Classify against the full IANA IPv4/IPv6 special-purpose registries (192.0.2/24, 198.51.100/24, 203.0.113/24, 192.0.0/24, 192.88.99/24, AS112/AMT, and in-2000::/3 Teredo/ORCHID/documentation), table-driven. Config (Codex #6 Medium): - Normalize the mcp block at load: coerce documented boolean strings (env-expanded "false" no longer leaves a security switch truthy) and require allowedHosts to be an array of exact lowercased hostnames (a scalar is wrapped, not substring-matched; non-strings rejected). Deferred (agreed): the pinnedHttpsFetch path is production-only (tests stub the fetch seam and exercise SSRF via the DNS seam); its lookup is a thin pass-through of the pre-validated addresses. Tests 938/936 pass (2 pre-existing skips); docs updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks — strong pass. All seven addressed on 1. Sibling-domain cookie injection (High) — fixed. The consent cookie is now a per-flow 2. 3. Preflight not connection-bound (High) — fixed with pinned-connect. The fetch now goes through 4. RFC 6890 special-use space permitted (Medium) — fixed. 5. Binding runs after login side effects (Medium) — fixed. The 6. CIMD config can fail open (Medium) — fixed. 7. One global consent cookie breaks concurrent flows (Medium) — fixed by the per-flow cookie in #1; parallel tabs now carry independent Verified: 🤖 Response by Claude (Fable 5) on Nathan's behalf |
heskew
left a comment
There was a problem hiding this comment.
Security and performance review:
- High: CIMD rejection paths can leave response bodies open. A malicious endpoint can send headers, then hold the socket open after non-200, non-JSON, or oversized responses.
- High: DNS throttling does not bound total CIMD fetch concurrency. Unique client_id URLs can fan out into unbounded outbound HTTPS work.
- Medium: MAX_CONCURRENT_DNS=8 can still starve Node's default 4-worker libuv pool under slow lookups.
- Cleanup: jwks and jwks_uri plumbing is not used in this PR. Defer it until there is validation and a consumer.
The SSRF guard, pinned connection, and browser-bound consent flow are justified and should stay.
Buffer.concat accepts Uint8Array[] directly (gemini review nit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if (!groups) return true; | ||
| const [g0, g1, g2, g3, g4, g5, g6, g7] = groups; | ||
| if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff) { | ||
| return isPrivateIpv4(`${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`); |
There was a problem hiding this comment.
🔴 Blocker — Spec violation: client_id allows query parameters
The CIMD draft prohibits query parameters in the client_id URL. Add a check for url.search in isCimdClientId to reject them.
| try { | ||
| clientIdHostname = escapeHtml(new URL(client.client_id).hostname); | ||
| } catch { | ||
| // Non-URL client_id — not a CIMD client; the section is omitted. |
There was a problem hiding this comment.
💡 Suggestion (non-blocking) — Avoid hardcoded path in interstitial form
Hardcoding /oauth/mcp/confirm breaks the flow if the plugin is mounted at a different path. Derive the base path from the request context instead.
Summary
Implements Client ID Metadata Documents (#166) — the modern MCP client-registration path (the draft spec marks DCR deprecated): an HTTPS URL is accepted as a
client_id, resolved by fetching the JSON metadata document it points to, validated, cached, and taken through a consent interstitial before the upstream redirect.src/lib/mcp/cimd.ts): URL-shape detection (https + non-root path, no userinfo/fragment/IP-literals/dot-segments) → SSRF-guarded fetch → validation → per-process cache.resolveClient()routes URL-shaped ids to CIMD and everything else to the existing DCR store; DCR is unchanged as the fallback.::compression, dotted-quad tails, zone indexes; fail-closed on anything unparseable) and in-2000::/3 special-use (Teredo/ORCHID/documentation) rejected. Pinned-connect: the fetch (https.request+ customlookup) connects to the exact address the gate validated while keeping the hostname for TLS SNI/cert — closing the rebind TOCTOU. No redirect-follow, only200 OKaccepted, one deadline (default 5 s) spanning DNS + connect + body, 64 KB cap, JSON-only, config values coerced fail-closed. Concurrent DNS resolutions are globally bounded (uncancellable getaddrinfo) with per-client_id dedup. Every gate rejection returns one genericinvalid_clientmessage (no internal-DNS probing); detail is server-side log only.client_idmust equal the fetched URL exactly; required fields (client_id,client_name,redirect_uris); redirect URIs validated with the same structural rules and the same redirect-host policy as DCR (dynamicClientRegistration.allowedRedirectUriHosts) —clientIdMetadataDocuments.allowedHostsgoverns the document host trust policy only.Cache-Control: max-ageclamped to [60 s, 24 h] (no-store/no-cachefloor at 60 s as deliberate DoS protection); failures never cached (draft requirement); cached records revalidated against the live redirect-host policy on every hit.client_idhost, the client name (unverified), and the redirect URI hostname (prominent loopback-impersonation warning when the redirect is localhost-only — spec MUST). Continue is a POST with a single-use, short-TTL token binding the full validated authorize params; the confirm handler resumes the existing upstream-redirect logic. Served withX-Frame-Options: DENY/frame-ancestors 'none'/Cache-Control: no-store.src/lib/mcp/consentBinding.ts): the interstitial sets a per-flow__Host--prefixed, Secure/HttpOnly/SameSite=Lax nonce cookie; sha256(nonce) travels inside the confirm token and the upstream OAuth state, and both/oauth/mcp/confirmand the upstream callback require a constant-time match before an MCP authorization code is issued. The callback checks it before the upstream code exchange andonLogin, so a mismatched flow triggers no side effects.__Host-blocks sibling-origin cookie injection; the per-flow name supports concurrent tabs. The callback also hard-rejects confirm tokens presented as upstreamstate(token purpose enforcement). DCR flows carry no binding and are untouched.mcp.clientIdMetadataDocuments(enabled— default ON whenmcp.enabled;allowedHosts; fetch timeout/size). AS metadata advertisesclient_id_metadata_document_supported: truewhen enabled.jwks/jwks_uri/token_endpoint_auth_methodare carried through on resolved records; v1 acceptstoken_endpoint_auth_method: noneonly —private_key_jwtactivates with Add RFC 7523 client_credentials grant (private_key_jwt, EdDSA) for headless agent auth #159's assertion verification.Where to focus review
consentBinding.ts+ its use inauthorize.ts/handlers.ts) — newest surface; verify the cookie→hash→state chain has no unbound leg. Deliberate consequence: CIMD authorization now requires cookies in the user's browser.cimd.tsguards) — the AS fetches an attacker-supplied URL; the DNS gate, deadline, size caps, and no-redirect posture are the containment. The IPv6 policy is deny-by-default (2000::/3 only).authorize.tsescapeHtml) —client_nameis attacker-controlled and rendered; every interpolation is escaped and tested.invalid_client(generic) instead of 500, so status codes don't reopen the internal-DNS probe channel;no-storeis floored at 60 s rather than honored literally (DoS floor).tokenIssuer.tsdeliberately untouched; only the token endpoint's client-record lookup is routed throughresolveClient.Review history
Two rounds of external review, all findings addressed:
f0da8a1): Claude + Codex + a security pass + two gemini bot rounds — 12 findings incl. two flow-level blockers (consent not browser-bound; confirm token accepted as upstream state). Fixed in195cf21; per-finding mapping.f0da8a1Codex pass): 3 High + 4 Medium — sibling-origin cookie injection,dns.lookupthreadpool DoS, preflight-not-connection-bound, RFC 6890 special-use ranges, binding-after-login-side-effects, config fail-open, concurrent-flow cookie collision. Fixed in7f07d87/6fb7dc8; per-finding mapping.Tests
938 total / 936 pass / 2 pre-existing skips: URL-shape + SSRF rejections (incl.
::, non-canonical loopback, v4-mapped hex, 6to4/ISATAP/Teredo embedded-v4, IANA special-use, dot-segments), 200-only, full-fetch deadline, config coercion, DNS concurrency-cap + dedup + pinned-address wiring, exact-match and required-field validation, cache TTL clamps + LRU bound + no-failure-caching + live policy revalidation, XSS escaping, interstitial flow (HTML → POST confirm → 302; single-use/expired/tampered tokens; anti-framing headers; per-flow__Host-cookie), consent-binding attack regressions on both legs (incl. no exchange/onLogin on mismatch), token-purpose rejection at the callback, config normalization, DCR-client flow unchanged, metadata flag gating.Docs updated:
docs/mcp-oauth.md+docs/configuration.md.Closes #166
🤖 Generated with Claude Code (Opus 4.8; review fixes by Fable 5)