Skip to content

Tidy up the readme - #5

Merged
heskew merged 1 commit into
mainfrom
readme
Oct 30, 2025
Merged

Tidy up the readme#5
heskew merged 1 commit into
mainfrom
readme

Conversation

@heskew

@heskew heskew commented Oct 30, 2025

Copy link
Copy Markdown
Member

No description provided.

@heskew
heskew merged commit 2ba566b into main Oct 30, 2025
3 checks passed
@heskew
heskew deleted the readme branch October 30, 2025 16:46
heskew added a commit that referenced this pull request Jul 10, 2026
…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>
heskew added a commit that referenced this pull request Jul 10, 2026
…sent interstitial (#166) (#167)

* feat: Client ID Metadata Documents (CIMD) with SSRF-guarded resolution 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>

* fix(cimd): separate document-host policy from redirect-host policy

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>

* fix(cimd): address external security review — consent browser binding, 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>

* fix(cimd): decode IPv4-in-IPv6 transition forms and fail closed on unknown 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>

* fix(cimd): address second Codex review — pinned-connect, __Host- per-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>

* chore(cimd): drop redundant Buffer.from in Buffer.concat

Buffer.concat accepts Uint8Array[] directly (gemini review nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cimd): reject query strings in CIMD client_ids

The draft (§3) says client_id URLs SHOULD NOT include a query string;
enforce it as part of the strict URL-shape profile — a dynamic server
could otherwise mint unlimited exact-match client_id aliases of one
document by echoing query variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73

* fix(cimd): tear down rejected connections, bound total resolution concurrency

Third external review batch:
- Abort the pinned connection when a response is rejected after headers
  (non-200, non-JSON, oversize) — the deadline timer is cleared on exit,
  so a hostile endpoint could otherwise hold rejected sockets open
  indefinitely.
- Bound TOTAL concurrent CIMD resolutions (DNS + connect + body) at 16,
  fast-rejecting past the cap; the DNS permit alone released too early
  to stop unique client_ids fanning out into unbounded HTTPS work.
- Drop MAX_CONCURRENT_DNS from 8 to 2 — below the default 4-thread
  libuv pool, so blackholed lookups can never starve fs/crypto users.
- Remove the jwks/jwks_uri carry-through: no consumer or validation
  until #159, which will add the plumbing alongside both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73

* chore(cimd): defense-in-depth minors from external agent review

- Add 224/4 multicast to the IPv4 special-use table so the table alone
  is complete (the a >= 224 early return remains as a fast path).
- Document that CIMD interactive authorization requires HTTPS — the
  __Host-/Secure consent cookie is silently dropped on plain-HTTP
  origins.
- Note readConsentNonce's base64url dependency and first-match
  semantics for future readers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73

* docs(cimd): spell out 224/4 multicast + 240/4 reserved in the SSRF header

Replace the cryptic 224/4+ shorthand in the module-header range list
(gemini nit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SerGP6Am3xz2CKyPgbRc73

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants