You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This turns the CIMD scoping plan (docs/scoping-cimd.md) into a design record of what shipped. CIMD landed in #191 (opt-in) and #203 (on by default, with OAUTH_CIMD_ENABLED as a kill switch), and is live in production. The CIMD code cites this document ("the trust policy of PR #143", "§3.4", "§3.5"), so merging it gives those references a home in the tree. Sections 3.4 and 3.5 keep their meaning.
What the record covers
The design as built, cited by symbol rather than line number:
the client_id shape rules and the trust gate;
the four authorize verdicts;
the strict SSRF-guarded fetcher (imcp2_core::public_fetch);
validation;
the shared cache, single-flight and in-flight bounds;
configuration and rollback (set OAUTH_CIMD_ENABLED=0 and redeploy; a restart alone isn't enough).
Decisions on the plan's seven open questions: reject untrusted URL client IDs; one list for the gate; in-memory cache with no floor; keep the path pin plus a same-origin rule; an 8 KB cap; no numbered draft pinned; no iss interaction.
Where the build departed from the plan, stated plainly:
the gate admits a vetted domain and its subdomains, not exact origins;
there is no cache floor and no ETag revalidation;
discovery's SSRF guard was tightened, which is not behavior-neutral;
the plan's rate-cap acceptance criterion was revised, not met: in-flight caps and a negative cache, with no rate cap.
DoS residuals as built: subdomain floods sidestep the per-host cap, and negative entries share the cache. Also the tests that pin the behavior, and an index of where the code lives.
The record was adversarially checked against main (8397307) before this push. 25 of 26 findings held up, and all 25 are applied.
Expands the top-ranked alignment improvement into an implementable plan.
Recommends a trust-policy-gated, additive design: fetch a Client ID
Metadata Document only when the client_id URL's host is already on the
curated vendor allow-list, keep open DCR for everything else, and never
trust the document's display fields. This collapses the new outbound-fetch
surface to a finite set of vetted hosts instead of an arbitrary-URL SSRF
primitive on the unauthenticated /authorize path, while still delivering
spec alignment and a DNS/TLS-authenticated domain key for branding.
Folds in the alignment PR's review correction: skills.rs's
markdown_url_for_base is NOT a usable SSRF guard (host-string compare
only); the real building block is discover.rs's address-pinned fetcher
(resolve_public_url + site_client + ssrf_redirect_policy), which the plan
extracts into a shared module as its Phase 0. Covers the fetch/validate/
cache flow, allow-list re-keying, branding subsumption, a security
analysis, phasing, and open questions — all cited against current code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Scopes a trust-policy-gated CIMD implementation while retaining DCR compatibility.
Changes:
Defines CIMD fetching, validation, caching, and trust-policy design.
Plans SSRF-safe fetcher reuse and branding integration.
Documents security considerations and implementation phases.
Suppressed comments (2)
docs/scoping-cimd.md:146
Keeping this redirect policy bypasses the trust-policy gate after the first request. redirect_hop_ok permits redirects to any global IP literal (discover.rs:1145-1153), so a vetted-host URL that exposes an open redirect can make /authorize fetch an arbitrary public destination despite the document's “finite set of vetted hosts” guarantee. Disable redirects for CIMD (or re-run the exact origin policy and address pinning for every hop).
- Keep the 15 s timeout, address pinning, `https`-only, and bounded redirects.
docs/scoping-cimd.md:174
DEFAULT_ALLOWED_REDIRECTS is not itself a set of exact vetted client-ID hosts: redirect_uri_permitted deliberately extends each entry to every subdomain (auth.rs:598-600), while the path pin supplies the remaining safety. Reusing that projection for a host-only CIMD policy would silently trust additional subdomains. Keep one vendor record if desired, but give it explicit exact CIMD origins rather than deriving them through the redirect matcher.
Introduce a **client-id-host trust policy** (the spec's "domain allowed via
trust policy"). Recommendation: derive it from the *same curated vendor set*
that backs `DEFAULT_ALLOWED_REDIRECTS` (`auth.rs:434`) so there is one source of
truth for "who is a vetted vendor," rather than a second independent list.
`redirect_uri_permitted` continues to gate the redirect leg.
… rate cap
Address the review on the CIMD scoping doc:
* Trust policy matches exact HTTPS ORIGINS, not bare hosts. resolve_public_url
uses the caller-supplied port (discover.rs:1113), so a host-only gate would
let https://claude.ai:8443/... reach an unvetted port; require the default
443 (and reject userinfo/other non-canonical authority) before any DNS/fetch.
* CIMD must use a STRICT capped reader, not discovery's best-effort one
(discover.rs:1207-1228 returns lossy/partial text without signaling — a
truncated body whose prefix is valid JSON would be accepted as metadata at
the auth boundary). Fail closed on over-limit/stream-error/invalid-UTF-8.
* client_id validation is a plain STRING match against the requested URL — no
normalization (hosted redirects use exact string membership, auth.rs:648, and
normalizing would mint aliases that disagree with the raw-URL cache key).
* Make the outbound-DoS control mandatory: the cache does NOT bound misses,
because an attacker can vary the URL PATH on a vetted host to mint unlimited
distinct keys and concurrent 15s fetches. A per-host + global concurrency/rate
cap (plus negative-caching) is now a Phase 1 acceptance criterion, not an
optional nicety.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
docs/scoping-cimd.md:59
HTTPS does bind the returned metadata values to the origin that served them, so they are not as unauthenticated as an arbitrary open-DCR body. What TLS does not establish is the semantic truth of the claimed product name or logo. Clarify that distinction while retaining curated branding.
CIMD provides **no signing or attestation** of the document's contents — the
display fields (`client_name`, `logo_uri`) are exactly as spoofable as a DCR
body. The only cryptographically meaningful fact is the **host** of the URL.
docs/scoping-cimd.md:273
A non-vetted URL-form identifier cannot silently fall back to the existing DCR path. /oauth/register always mints an opaque client-<uuid> (src/auth.rs:2148), and validate_client only looks up that supplied identifier, so the URL will never be a DCR registration. The compatibility section should instead explain that such clients must register and retry with the returned opaque ID.
1. **Non-vetted URL `client_id`:** hard reject, or silently fall back to DCR? A
reject is clearer and avoids a confusing partial-support surface; a fallback
is more permissive. Recommend reject with an allow-listing contact.
…gate)
Review fix: the doc said to keep discovery's redirect policy, but
ssrf_redirect_policy / redirect_hop_ok (discover.rs:1145-1153) follow any
global-IP literal and any same-host hop WITHOUT a port check — so a vetted
client_id could redirect the fetch to an unvetted public IP or to
vetted-host:8443, escaping the exact-origin gate of §3.1. A CIMD is served
directly at its URL, so §3.3 now says to disable redirects entirely (or, if
truly needed, require an exact same-origin hop), never the discovery policy.
§5's SSRF bullet notes the control.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
docs/scoping-cimd.md:282
This fallback option is not implementable with the existing DCR contract: /oauth/register consumes a registration request and returns a newly minted opaque ID, so /oauth/authorize cannot transparently convert a URL client ID into DCR. Make rejection the decision; clients choosing DCR must register first and retry with the returned ID.
1. **Non-vetted URL `client_id`:** hard reject, or silently fall back to DCR? A
reject is clearer and avoids a confusing partial-support surface; a fallback
is more permissive. Recommend reject with an allow-listing contact.
docs/scoping-cimd.md:243
Keep the negative-cache requirement scoped to document-intrinsic/non-CIMD failures. As written, “failed” can include the request-specific redirect validation defined in §3.4, allowing one malicious redirect request to install a URL-level negative entry for an otherwise valid client.
vetted host cannot fan out, and negative-cache failed/for-non-CIMD URLs so a
docs/scoping-cimd.md:74
OAUTH_ALLOWED_REDIRECT_PREFIXES does not override this list: allowed_redirects() appends parsed entries to DEFAULT_ALLOWED_REDIRECTS (src/auth.rs:460-475), and the README likewise documents it as additive (README.md:660-663). Calling it overridable can mislead operators into expecting that defaults can be removed.
is exempt (RFC 8252). `allowed_redirects()` (`auth.rs:457`) is overridable via
`OAUTH_ALLOWED_REDIRECT_PREFIXES`.
docs/scoping-cimd.md:136
A URL-form request cannot silently fall back to DCR. DCR requires a separate registration body and always mints a different opaque client-<uuid> (src/auth.rs:2058-2065, 2148-2159), while /authorize only has the presented URL and one redirect. Treating the URL as an opaque DCR ID would simply fail lookup, so this branch must reject and tell the client to register/retry instead.
This issue also appears on line 280 of the same file.
- If the URL's **origin** (scheme + host + default 443 port) is **not** on the
client-id trust policy → **reject** with a clear error naming the contact
for allow-listing (mirroring the DCR hosted-redirect rejection). *(Reject vs
silent DCR-fallback is an open question — see §8.)*
Review fix: §3.4 folded request-specific redirect checks into "validation,"
so negative-caching every validation failure by URL would let an attacker
poison a valid client — request a real CIMD URL with a non-member
redirect_uri, the URL gets cached as invalid, and legitimate redirects then
hit the negative entry. Split validation into document-intrinsic (cacheable:
fetch/JSON/client_id==URL) and per-request (never cached: redirect membership
+ redirect_uri_permitted, re-run every request against the positively-cached
document). §3.5 and the §5 DoS bullet now negative-cache only
document-intrinsic failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (5)
docs/scoping-cimd.md:208
“Didn't resolve” includes transient DNS, connection, timeout, 429, and 5xx failures. Treating all of these as normal negative entries can keep a legitimate CIMD unavailable after a brief vendor outage. Separate deterministic invalid-document failures from transient fetch failures, using only a short retry backoff for the latter.
- Also **negative-cache** URLs that fail a **document-intrinsic** check (§3.4) —
didn't resolve to a valid CIMD, bad JSON, `client_id` ≠ URL — so a repeat of
the same bogus path is cheap. **Never** negative-cache a **per-request**
docs/scoping-cimd.md:59
This overstates the spoofing risk and contradicts the preceding TLS-authentication explanation. Unlike open DCR, an arbitrary caller cannot choose CIMD display values; HTTPS authenticates them as assertions by the URL's host. They still do not attest a product/legal identity, so curated branding is reasonable, but the document should state that narrower rationale.
CIMD provides **no signing or attestation** of the document's contents — the
display fields (`client_name`, `logo_uri`) are exactly as spoofable as a DCR
body. The only cryptographically meaningful fact is the **host** of the URL.
docs/scoping-cimd.md:190
This membership rule omits the existing RFC 8252 loopback exception. redirect_allowed accepts an exact match or loopback_match (src/auth.rs:648-650), allowing a native client to bind an ephemeral port. A CIMD client listing http://127.0.0.1/callback would otherwise fail when authorizing with its runtime port.
- The request's `redirect_uri` is a member of the cached document's
`redirect_uris` **and** still passes `redirect_uri_permitted` (`auth.rs:545`).
docs/scoping-cimd.md:202
A TTL floor does not honor the publisher's cache policy: it overrides no-store, no-cache, or max-age=0 and can keep accepting a redirect URI after the vendor tries to revoke it. Retain the ceiling for stale-data safety, but honor immediate revalidation/eviction directives and use the required limiter to control re-fetch load.
- Cache **validated** documents keyed by the `client_id` URL, honoring
`Cache-Control` / `ETag` with a **TTL floor and ceiling** so a hostile
`max-age` can neither pin a stale doc forever nor force a re-fetch per request.
docs/scoping-cimd.md:224
The current redirect entries cannot be reused as exact origins: they are registrable-domain rules that deliberately match every subdomain (src/auth.rs:594-600), and some intended hosts differ from the entry (for example, cursor.com covers www.cursor.com). Deriving the CIMD gate directly either trusts all subdomains, violating the exact-origin boundary, or trusts only apex hosts and rejects intended clients. Use one vendor record with separate explicit client-ID origins and redirect domain/path rules.
Introduce a **client-id-ORIGIN trust policy** (the spec's "domain allowed via
trust policy") — exact `https://<host>` entries on the default 443 port, matched
as origins so the port gap in §3.1 cannot slip a non-default port past a host
check. Recommendation: derive it from the *same curated vendor set* that backs
`DEFAULT_ALLOWED_REDIRECTS` (`auth.rs:434`) so there is one source of truth for
aterga
pushed a commit
that referenced
this pull request
Sep 3, 2026
Align the Client ID Metadata Document support with the scoping in
PR #143 and the third review round.
Trust policy: a URL client_id is fetched only when its origin is a
vetted vendor's — a host on or under a domain of the hosted-redirect
allow-list, on the default https port. Anything else is refused before
any request goes out and, like a hosted redirect off the allow-list,
pointed at the allow-listing contact (403 invalid_client, or the
not-approved page for a browser). The one vendor list decides both
where a code may land and whose document this server will GET.
Opt-in: CIMD is advertised and URL client_ids accepted only where the
deployment sets OAUTH_CIMD_ENABLED=1. The deploy template takes the
variable from the GitHub Environment, so a routine deploy never
switches the directory clients over by itself; unsetting it is the
rollback.
Negative cache: a failure that is about the URL itself (404, a
redirect, not JSON, about another URL, too large, not UTF-8) is
remembered for a minute so a repeat costs no fetch. A transient one
(deadline, connection, 5xx, 429) is not, and a per-request failure (a
redirect the document does not list) never is, so a probe cannot lock
out a real client. The fetcher's errors are typed to make that split.
Single-flight shares the outcome: concurrent misses for one document
share the one fetch's result — failure and uncacheable document
included — instead of re-fetching serially behind it, and the flight
entry is retired only by the flight that made it.
A document may list no more redirect_uris than a DCR registration.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
…cher
The tenth review round, four findings:
- The SSRF guard also refuses 100::/64, the IANA discard-only block.
- A document's redirect_uris are kept only when a DCR registration
could have registered them (redirect_uri_permitted: loopback, or on
an allow-listed host and pinned path, never with query or fragment),
besides being same-origin. A loopback entry with a fragment would
otherwise have matched a fragment-free request the port-agnostic
match ignores fragments on — a redirect DCR refuses.
- Fetches are rate-limited, not only bounded in flight: a token bucket
per process (60 a minute) and per vetted domain (30 a minute), since
an origin answering at once returns its permit at once and distinct
paths defeat the negative cache. The rate cap PR #143 §5 requires.
- Every request in a flight holds a guard, and the last one out retires
the flight. A fetcher cancelled mid-way therefore leaves the flight
where its waiters and any newcomer find it, and a waiter takes over
the one fetch, instead of waiters and newcomers fetching the same
document on two flights. The fixture gained a hanging origin to test
the handover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
)
* Support Client ID Metadata Documents (CIMD) as a registration mode
Both directories steer servers to CIMD over DCR: Anthropic recommends it
for directory listings, and ChatGPT prioritises it. Each selects CIMD when
the AS metadata advertises `client_id_metadata_document_supported: true`
alongside `none` in `token_endpoint_auth_methods_supported` — so the flag
must never be advertised ahead of the implementation, or every Claude
connection fails with `invalid_client`.
With CIMD the `client_id` IS an https URL, and the RFC 7591-shaped JSON at
that URL is the client's registration. Nothing is stored per client, so a
directory client that connects thousands of times no longer mints a DCR
registration each time.
The document is fetched under the discovery module's SSRF guard (https
only, public addresses only, pinned against rebinding, redirect hops
re-checked), now exposed as `imcp2_core::public_fetch::fetch_public_document`
— strict where the crawl is opportunistic: a body over the cap (8 KiB), a
transfer cut off mid-body, or an answer from a redirect target is an error,
never a shorter document. 5 s timeout, at most 8 fetches in flight (an
excess request is told to retry, not queued), and a bounded cache honouring
the origin's `max-age` clamped to 1 min–24 h, 10 min by default. Failures
are never cached.
Validation follows the draft and Anthropic's reference server: the
document's `client_id` must equal the URL exactly; it may carry no secret;
it must be able to authenticate as a public client (ChatGPT's document
prefers `private_key_jwt` but lists `none`, which is what it uses here);
and of its `redirect_uris` only loopback ones and those same-origin with
the document URL are kept, so a self-asserted document cannot point the
code at another party. The requested redirect then gets EXACTLY the checks
a DCR registration gets — a match against those URIs (loopback
port-agnostically) AND the hosted-redirect allow-list — and the allow-list
is checked BEFORE any fetch, so a redirect this server would refuse anyway
never costs an outbound request. A fetch failure is `temporarily_unavailable`
(retry), an invalid document `invalid_client`; neither reflects the
caller-supplied URL to the browser.
`OAUTH_CIMD_DISABLED=1` withdraws the advertisement and the mechanism at
deploy time without a rebuild, because hosted Claude's own document URL is
not published and could not be verified here; clients re-read the metadata
within minutes and fall back to DCR.
Tests use ChatGPT's and Claude Code's real documents (as served
2026-09-03) as fixtures, and a process-global stand-in for the web so the
authorize path is exercised end to end without network: allow-list before
fetch, caching, cross-origin refusal, port-agnostic loopback, kill switch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Report CIMD advertisement on the status dashboard
The as-metadata check's detail line now ends in `CIMD=on|off`, read from
`client_id_metadata_document_supported`, alongside the issuer and PKCE it
already reports. CIMD is the registration mode both directories prefer, and
`OAUTH_CIMD_DISABLED` can withdraw it at deploy time, so the dashboard is
where an operator confirms which mode the production instance is actually
offering — and where a regression that dropped the flag would show. Reported,
not required: the switch being off is a state to see, not an outage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Use the scanner's canonical private address in the SSRF-guard test
The guard test probed a private 10/8 address that is not one of the example
values the internal-identifier scan strips before matching, so the scan
flagged it. Use the canonical example address the scan allows and that
discover.rs's own guard tests use. Same test, same refusal, no suppression
marker needed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Tighten the CIMD fetch and cache as review found
Four findings from review, each a real gap between what the code promised
and what it did:
Redirects. `public_fetch` followed same-host and public-IP hops under the
crawl's redirect guard and then compared origins, so a same-origin redirect
to another path put a different document behind the client_id URL. Now no
redirect is followed at all: a 3xx is a non-success answer and is refused,
which is what the module doc had claimed.
Deadline. The caller's timeout started after `resolve_public_url`, leaving
DNS resolution unbounded — in the CIMD path, a slow resolver could hold one
of the eight in-flight permits past the five seconds the authorize budget
allows. One `tokio::time::timeout` now covers resolution, connect, response
and body. imcp2-core gains tokio's `time` feature for it.
Cache floor. `no-store`, `no-cache` and `max-age=0` were clamped up to a
minute and the document reused meanwhile, defeating an origin's explicit
instruction and keeping a withdrawn redirect authorized. The floor is gone:
a zero lifetime means the document is not cached, and a positive `max-age`
is honoured as given up to the 24 h ceiling. The floor's DoS rationale did
not hold — an invalid document is never cached either, so a stranger could
always force a fetch per request; the in-flight bound is what contains that.
Overflow. `max_bytes + 1` wrapped for `usize::MAX`; it saturates now.
Tests: a zero timeout expires during resolution of a public name and is
reported as the deadline, not the guard; an uncapped read is accepted; a
`no-store` document authorizes once and is refetched, not reused; the TTL
test pins "no floor, ceiling kept, zero means don't cache".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Make the fetch deadline the only timeout
CI caught `deadline_covers_resolution` racing: on a runner whose resolver
answers before tokio's timer tick, the fetch got past DNS and reqwest's own
per-request `.timeout(ZERO)` failed it with a request error, not the
deadline's. Two timeouts over one operation is the flaw. The client now sets
none of its own; the outer `tokio::time::timeout` is the single deadline,
dropping the future on expiry aborts the connection, and the caller sees the
same error wherever the time ran out. The test asserts exactly that and is
deterministic for it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Harden the CIMD fetch path as the second review round found
Six findings, each a gap between what the code claimed and what it did:
Freshness. `Cache-Control` was read from one header line and its `max-age`
reused as a fresh lifetime. HTTP combines all lines (a `no-store` on the
second counts) and freshness is `max-age` less the response's `Age`, so a
CDN answer one second from expiry gave us a new day. `public_fetch` now
reports the remaining lifetime from the combined fields, `Age` subtracted.
Decoding. The body went through the crawl's lossy UTF-8 read, so a byte
that was not UTF-8 became U+FFFD and the document still parsed. The strict
path now reads bytes (`read_capped_bytes`, which the lossy read is built on)
and refuses invalid UTF-8; the document parsed is the one served.
Media type. A 200 with any `Content-Type` was parsed as JSON. A metadata
document must be served as `application/json`; anything else, by essence
(parameters and case aside), is now `invalid_client` before parsing.
Thundering herd. Concurrent misses for one document each fetched it and
each spent a permit. A per-`client_id` single-flight lock now lets the first
fetch and the rest read the cache after it.
Monopolisation. Eight slow distinct URLs on one host could hold every
permit. A per-host cap of two now bounds any one host; the global bound of
eight stays. Neither queues: an excess request is told to retry.
Redirects were untested. `accept` is split from the sending so the
acceptance rules run against synthetic responses with no network: every 3xx
is refused as "not followed", non-2xx refused, the cap exact, non-UTF-8
refused, `Age` and multi-line `Cache-Control` honoured. imcp2-core gains
`http` as a dev-dependency for them (Cargo.lock: one edge).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Gate CIMD on the vendor trust policy and make it opt-in
Align the Client ID Metadata Document support with the scoping in
PR #143 and the third review round.
Trust policy: a URL client_id is fetched only when its origin is a
vetted vendor's — a host on or under a domain of the hosted-redirect
allow-list, on the default https port. Anything else is refused before
any request goes out and, like a hosted redirect off the allow-list,
pointed at the allow-listing contact (403 invalid_client, or the
not-approved page for a browser). The one vendor list decides both
where a code may land and whose document this server will GET.
Opt-in: CIMD is advertised and URL client_ids accepted only where the
deployment sets OAUTH_CIMD_ENABLED=1. The deploy template takes the
variable from the GitHub Environment, so a routine deploy never
switches the directory clients over by itself; unsetting it is the
rollback.
Negative cache: a failure that is about the URL itself (404, a
redirect, not JSON, about another URL, too large, not UTF-8) is
remembered for a minute so a repeat costs no fetch. A transient one
(deadline, connection, 5xx, 429) is not, and a per-request failure (a
redirect the document does not list) never is, so a probe cannot lock
out a real client. The fetcher's errors are typed to make that split.
Single-flight shares the outcome: concurrent misses for one document
share the one fetch's result — failure and uncacheable document
included — instead of re-fetching serially behind it, and the flight
entry is retired only by the flight that made it.
A document may list no more redirect_uris than a DCR registration.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Close the fourth review round's gaps in the CIMD fetch path
- A resolver failure is a failure of the moment, not of the URL: the
SSRF guard now reports it apart from its refusals (ResolveError), the
fetcher maps it to Unreachable, and the client-metadata cache no longer
remembers a DNS outage as "no document there" for a minute.
- The guarded fetch takes no proxy from the environment: a proxy would
resolve the host itself and the address pin would bind nothing.
- A max-age given more than once is honoured at its most restrictive
value, so a duplicate can never extend freshness.
- A document's redirect_uris are bounded in length as well as count,
exactly as a DCR registration's are, so a document admits no redirect
DCR would refuse.
- The cache, single-flight map and in-flight bounds are one per process,
shared by every store the binary mounts, so the documented limits hold
per process rather than multiplying with the mounts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Read Cache-Control as the shared cache this is, and tighten two edges
The fifth review round's three findings:
- The client-metadata cache is process-wide, so it is a shared cache
and must read Cache-Control as one: `private` forbids it reuse,
`s-maxage` is its lifetime whenever present (over `max-age`), and
directives are matched by name so an argued `no-cache="..."` counts.
- A present but non-string `token_endpoint_auth_method` was read as
absent and so as `none`; a wrong type is now a malformed document.
- HTTP 408 is a request timeout, about the moment like 429 and 5xx: it
is now retryable rather than remembered as an invalid URL.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Bound the CIMD client_id and key per-host slots by one host spelling
The sixth review round:
- A client_id URL is bounded in length before it is treated as CIMD at
all: it becomes a key of the process-wide cache and single-flight map,
so an unauthenticated caller must not size those entries at will. The
cap is the one a redirect URI already has; the real identifiers are
under 100 bytes.
- The per-host in-flight slots are keyed by the same one-spelling-per-
host rule as the trust policy (lower-case, no trailing dot), and a
trailing-dot host is refused as non-canonical up front, so no spelling
of a vetted host buys a second quota.
- The README describes the public-client rule as implemented — `none`
given or absent, or `none` among the supported methods — rather than
as one exact field value.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Require a metadata document to name the authorization-code flow
The seventh review round: a document's `grant_types` and `response_types`
were ignored, so one declaring only `client_credentials` / `token` was
accepted into a flow it could never complete — and DCR refuses the
equivalent registration. Absent, each means RFC 7591's default; present,
each must be a string array that includes `authorization_code` or `code`
respectively, or the document is refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Retire a CIMD flight when the request fetching for it is dropped
The eighth review round: a request dropped mid-fetch — the client reset
the stream, so the authorize future was dropped at an await — left its
single-flight entry in the process-wide map for good, and unique URLs on
a vetted host would grow that map without bound. The fetching request
now holds a drop guard that retires its own entry (never a newer one)
whether it finishes or is dropped; waiters keep their handle to the
flight and one of them fetches. A test aborts the leader mid-fetch and
checks that no entry, host slot or permit is left behind.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Refuse deprecated IPv6 site-local addresses in the SSRF guard
The ninth review round: the shared classifier excluded fc00::/7 and
fe80::/10 but not fec0::/10 — site-local, deprecated by RFC 3879 yet
still routable on legacy networks — so a vetted host resolving there
would have been accepted and pinned. It is refused now, and both guard
regression tests cover it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Bound the CIMD fetch rate and keep one flight through a cancelled fetcher
The tenth review round, four findings:
- The SSRF guard also refuses 100::/64, the IANA discard-only block.
- A document's redirect_uris are kept only when a DCR registration
could have registered them (redirect_uri_permitted: loopback, or on
an allow-listed host and pinned path, never with query or fragment),
besides being same-origin. A loopback entry with a fragment would
otherwise have matched a fragment-free request the port-agnostic
match ignores fragments on — a redirect DCR refuses.
- Fetches are rate-limited, not only bounded in flight: a token bucket
per process (60 a minute) and per vetted domain (30 a minute), since
an origin answering at once returns its permit at once and distinct
paths defeat the negative cache. The rate cap PR #143 §5 requires.
- Every request in a flight holds a guard, and the last one out retires
the flight. A fetcher cancelled mid-way therefore leaves the flight
where its waiters and any newcomer find it, and a waiter takes over
the one fetch, instead of waiters and newcomers fetching the same
document on two flights. The fixture gained a hanging origin to test
the handover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Count a response's apparent age, spend rate tokens only on a fetch
The eleventh review round, three findings:
- The SSRF guard also refuses 2001:2::/48 (benchmarking), 2001:10::/28
(ORCHID, deprecated) and 2001:20::/28 (ORCHIDv2, not routable).
- Freshness subtracts the response's CURRENT age — the larger of its Age
and the time since its Date — not Age alone, so an answer a cache held
for an hour without saying so is not given a new lifetime. httpdate
(already in the lockfile through hyper) parses the Date.
- Rate tokens are taken only once a host slot and a permit are held,
right before the fetch, so a request refused for congestion drains no
budget and a burst during congestion cannot lock the real clients out
of the minute once it clears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Retire a published CIMD flight at once; refuse two more IPv6 ranges
The twelfth review round:
- A flight stayed in the single-flight map until its last holder left,
so requests arriving after the outcome was published could keep
joining it and reusing that outcome — for a `no-store` document, or a
failure that may be over, without the fresh fetch the origin asked
for. The fetcher now retires the flight the moment it publishes;
waiters read the outcome from the handle they hold, and a newcomer
goes to the cache or fetches afresh. The last-holder rule remains for
the flight whose fetcher was cancelled before publishing, so a waiter
still takes over and nothing is left behind.
- The SSRF guard also refuses 3fff::/20 (documentation, RFC 9637) and
5f00::/16 (SRv6 SIDs, not globally reachable, RFC 9602).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Say that the CIMD rollback needs a redeploy, not just the variable
The thirteenth review round: the runbook read as if unsetting the GitHub
Environment variable disabled CIMD by itself. The value is rendered into
the systemd unit at deploy time and read once at start-up, so an operator
following that during an outage would have left CIMD on. The deploy
README, the unit template, the top-level README and the code comments
now say: unset the variable AND redeploy (the same ref will do — no
rebuild); changing the variable alone changes nothing on the host.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Default-deny 2001::/23, treat a malformed max-age as stale, retire before publishing
The fourteenth review round, three findings:
- The SSRF guard refuses all of 2001::/23 (IETF protocol assignments,
not globally reachable by default) and admits only the IANA registry's
reachable exceptions by name — PCP and TURN anycast, AMT, AS112-v6,
Drone Remote ID — so an unassigned or non-routable address in the
block (Teredo, benchmarking, ORCHID, or nothing yet) is refused until
audited rather than accepted until noticed.
- A max-age or s-maxage given without a valid number is stale (zero),
never the ten-minute default lifetime, per RFC 9111 §4.2.1.
- A flight is retired from the single-flight map before its outcome is
published, still under the flight's lock, so no request can join it in
between and reuse a no-store document or a failure that may be over.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Parse Cache-Control quoted-strings, take the client_id as given, admit DNS-SD anycast
The fifteenth review round, three findings:
- Cache-Control is split into directives only at commas outside a
quoted-string, with escapes honoured, so an extension's quoted
argument can no longer smuggle in an `s-maxage` of a day; a value
whose quoted-string never closes is not reused at all.
- A CIMD client_id is taken as given — the string its document must
repeat byte for byte, and the cache key — rather than required to be
in canonical form; only the host is normalised, for the trust policy
and the per-host quota. The identifier is carried through fetch and
cache unchanged, so a document repeating an upper-case host or an
explicit :443 is matched.
- 2001:1::3 is the DNS-SD Service Registration Protocol anycast address
(RFC 9665), globally reachable, and is admitted with the other
exceptions in 2001::/23; 2001:1::4 stands in as the unassigned example.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Honour Expires, accept an upper-case scheme, fix a test's contract
The sixteenth review round, three findings:
- Where Cache-Control grants no freshness, `Expires` decides — relative
to `Date`, or to receipt without one — with an already-past or invalid
value ("0") meaning stale rather than the ten-minute default lifetime.
- A CIMD client_id is parsed, not prefix-matched, so `HTTPS://…` is the
https URL it is; a DCR id parses to nothing as before.
- The client_id shape test's doc comment described the canonical-form
contract the previous round removed; it now describes the actual one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Refuse the 6to4 relay block, Vary: *, and a client_id the parser would alter
The seventeenth review round, three findings:
- The SSRF guard refuses 192.88.99.0/24, the deprecated 6to4 relay
anycast block (RFC 7526), bar 192.88.99.2, the 6a44 relay anycast
(RFC 6751) the registry marks globally reachable.
- A response with `Vary: *` has no freshness for a shared cache, whatever
its lifetime says: it can never match a later request (RFC 9111 §4.1).
- A CIMD client_id is refused when the WHATWG parser would silently alter
it — tab/newline/CR it strips from anywhere, or an empty `@` userinfo
it erases — checked on the raw string as the issuer matcher already
does, so the identifier taken as given is the URL that was fetched.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Saturate an unparseable Age, and log CIMD failures only where a fetch happened
The eighteenth review round, two findings:
- An `Age` that is sent but does not parse — overflowing, or no number —
is the greatest age rather than none (RFC 9111 §1.2.2), so a response
of unknowable age is not given a whole lifetime.
- The per-request diagnostics on the unauthenticated authorize path —
an untrusted client_id origin, an invalid document (a negative-cache
hit) and an unavailable one (a refused permit or budget) — are debug
now, since a flood of requests would otherwise be a flood of log lines
carrying caller-chosen text without a single fetch. The invalid and
unavailable outcomes are logged at warn where the fetch happens, which
the rate limiter bounds.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Bring two CIMD doc comments up to date with the negative cache
The nineteenth review round: the cache-ceiling rationale still said an
invalid document is never cached (it is, for CIMD_NEGATIVE_TTL) — the
remaining fetch-per-request case is a valid document whose origin
forbids reuse — and the CimdError variants named guard refusals, sizes
and statuses as "unavailable" when classify_fetch_error makes most of
them "invalid". Both now describe the contract as implemented.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Describe PublicDocument::cache_max_age as freshness computes it
The twentieth review round: the public field's doc still named only
max-age and Age. It now describes the value as computed — s-maxage or
max-age from every Cache-Control line, else Expires less Date, minus the
current age — and says when Some(0) and None occur.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Fold every Age line conservatively; refuse a client_id the parser would trim
The twenty-first review round, two findings:
- Every `Age` line counts and the greatest wins, and one that is not
even ASCII is the greatest age like any other unparseable one — only
the first line was read, and a non-ASCII value counted as zero.
- The WHATWG parser also trims leading and trailing C0 controls and
spaces from a URL, so a raw client_id beginning or ending with one is
refused, as tab/newline/CR and an empty userinfo already were.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Default-deny native IPv6 outside 2000::/3 in the SSRF guard
The twenty-second review round: the IPv6 classifier was default-allow —
any address not on its list of exclusions was public — so an address in
space IANA has not allocated for global unicast (4000::1, say) passed.
Global unicast is allocated only from 2000::/3, so a native address
outside it is now refused without being named, which also covers the
loopback, discard, NAT64, SRv6, unique-local, link-local, site-local and
multicast ranges the list used to enumerate; within 2000::/3 the
2001::/23 default-deny and the documentation and 6to4 carve-outs remain.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Accept only 200 OK as the document; read an undecodable Cache-Control as no reuse
The twenty-third review round, two findings:
- Only a `200 OK` is the document. Any other 2xx was accepted before — a
`206 Partial Content` fragment that happens to parse as JSON would have
been validated as the complete metadata document, against the strict
reader's completeness guarantee — and is now refused like a 3xx or 4xx.
- A `Cache-Control` or `Vary` line the header cannot decode (a quoted
argument may carry obs-text) is read as forbidding reuse rather than
skipped, or an undecodable `max-age=0` beside a decodable `max-age=86400`
would be dropped and the day honoured.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Apply a response's age to the default CIMD cache lifetime too
The twenty-fourth review round: where the origin sent no freshness
information, the ten-minute default was granted in full, so a document
some cache along the way had already held for a day (Age: 86400, or an
old Date) got ten fresh minutes here. The fetched document now reports
its current age alongside the remaining freshness, and the default
lifetime is that default less the age; an origin's own value is already
net of it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Refuse a CIMD client_id containing a backslash
The twenty-fifth review round: the WHATWG parser reads a backslash as a
slash in an https URL — `https:\\host\path` parses as `https://host/path`,
and one in the authority ends it before an `@` the raw scan expects
there — so a raw identifier with a backslash was not the URL that was
parsed and fetched. Any backslash is refused on the raw string now,
alongside tab/newline/CR, trimmed controls and an empty userinfo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Treat 421 and 425 as failures of the moment, and test the retry response
The twenty-sixth review round. A document fetch answered 421 Misdirected
Request or 425 Too Early was classified as a failure of the URL, so the
client was refused as unknown and the refusal remembered for the negative
TTL, though both statuses are defined as ones the client may retry: 421
is about the connection the request arrived on, 425 about the moment.
Both join 408 and 429 as failures of the moment, told to retry and never
remembered.
The endpoint's retry response — 503 temporarily_unavailable to a
programmatic caller, the sign-in error page to a browser — was covered
only through the verdict it maps. It is now exercised directly, for an
unreachable origin and for a 425 answer: the status and error code, that
neither body reflects the client_id URL or the cause, and that nothing
is remembered so the next request fetches again. The test fixture gains
an `answer(url, status)` for any non-200 status; `not_found` is built on
it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Parse Age and max-age as delta-seconds: ASCII digits only
HTTP's delta-seconds is 1*DIGIT, but Rust's u64 parser also takes a
leading plus sign, so `Age: +1` counted as a one-second age and
`max-age=+300` as a five-minute lifetime, where every other malformed
value is read as maximally stale. Both now go through delta_seconds(),
which requires a non-empty, ASCII-digit-only value before parsing, so a
signed value is the greatest age and a zero lifetime like the rest.
Regression cases cover `+1` for Age (freshness and current_age) and
`+300`/`+60` for max-age and s-maxage, plus a unit test for the helper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Refuse any CIMD client_id the URL parser would rewrite
Only tab/newline/CR, leading or trailing controls, backslashes and an
empty userinfo were refused on the raw identifier, so an internal space
or control, a quote, brace, backtick or non-ASCII character in the path
(each percent-encoded by the WHATWG parser), a percent-encoded or IDNA
host, an odd spelling of the default port, or a dot segment still passed
- and the URL fetched was then not the identifier taken as given, which
is the cache key and what the document must repeat byte for byte.
One rule now covers all of it, present and future: the parsed URL must
serialise back to the raw string (parsed_as_given), bar the scheme's and
the host's ASCII case and an explicit :443, which the parser normalises
and a CIMD identifier may spell either way. The four special cases fall
under it and are gone. The shape test covers each rewrite the parser
makes and the spellings that survive it (case, :443, a trailing dot,
percent-encoding as given, a doubled slash); a dot-segment spelling it
accepted before is refused now, since the parser resolves it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Reserve half of every CIMD fetch bound for documents already validated
The in-flight and rate bounds on metadata-document fetches were a
denial-of-service lever: a caller keeping a vendor's share spent with
made-up paths on a vetted host (each a fetch, each a fresh negative
entry) had every client of that vendor told to retry the moment its
document expired, at a request a second or two. Every bound is now split:
a fetch for a document this process has never validated (a URL never
seen, or remembered only as invalid) may use at most half of it, and a
fetch refreshing a document it holds - a positive cache entry, fresh or
stale - may use all of it. Only a vetted origin can put a document in
the positive cache, so the reserve is out of an unauthenticated caller's
reach: the flood is held to the unknown share, the vendors' real
documents refresh from the rest. The whole is doubled (16 in flight, 4
per host, 240 a minute, 120 per vendor domain), so the unknown share is
the bound that was reviewed and the reserve is on top; both remain far
below anything a vendor's edge would notice. Room-making in the cache
drops expired negative entries before stale positive ones, so a flood
cannot strip a real document of its standing either.
Tests: the rate test spends the unknown shares and shows the known
document refreshing through them; a new test does the same for the
per-host slots and the process's permits, and shows the whole still
bounding the refreshes; the cancelled-fetch test checks the unknown
permit comes back too. README numbers updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Drop the CIMD fetch rate limiter; keep the in-flight bounds, raised
At the author's request, to keep the PR simple: the per-process and
per-vendor token buckets go, with their constants, tests and README
mention. The in-flight bounds stay and are raised to 16 overall and 4
per host. A slot frees within the fetch deadline, so unlike a spent
minute's budget an in-flight bound is not something a flood of made-up
URLs can hold against the vendors' real clients. This also reverts the
reserve split of b46e0ea, which only the rate limiter made necessary.
Comments in the touched code are trimmed to the point.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Parse the client metadata document into a serde struct
Per review: the RFC 7591 members this server reads are a Deserialize
struct now, and parse_client_metadata deserialises into it instead of
walking a serde_json::Value, so a member of the wrong type fails at the
parser with serde's own message. The checks that are policy rather than
shape - the client_id match, no secret, a public client, the one flow,
the redirect_uris bounds and the own-origin filter - stay as they were.
The test no longer pins the hand-rolled shape messages.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Refuse an explicit null in the metadata document's optional members
serde reads a JSON null into Option<T> as None, so since the struct
refactor "token_endpoint_auth_method": null (or grant_types,
response_types, the secret members) counted as omitted and inherited
the defaults, where the hand-rolled parser had refused it as the wrong
type. The optional members now deserialise through `present`, which
reads a present member as T itself - null is a type error - with
serde's default supplying None for an absent one. Null cases added to
the parsing test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Test the off-origin redirect with the port in the authority
The "different port" case appended :8443 after the path, so it tested
a different path, not a different origin.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
* Warn about a vendor's fetch failures at most once a minute
With the rate cap gone, the in-flight bound alone no longer bounds the
warn-level log at the fetch site: a fast 404 frees its slot at once, so
a caller rotating made-up paths (or unresolvable subdomains) under a
vetted domain got one warning per fetch. The two warnings are sampled
now, once a minute per vetted domain and at debug otherwise, which
keeps the signal an operator watches for during rollout while bounding
the log by the finite vetted set. Unit test for the sampler.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xd7VT72Qt16qiAJynu9EKj
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
CIMD shipped in #191 (opt-in) and #203 (on by default, with
OAUTH_CIMD_ENABLED as a kill switch), so the scoping plan now records
what was built instead of proposing it:
- the implemented gate, flow, fetcher, validation, caching, single-flight
and in-flight bounds, configuration and rollback, by symbol rather than
by line number;
- answers to the plan's open questions, and where the build departed from
the plan: a domain-and-subdomain gate instead of exact origins, no cache
floor or ETag revalidation, the same-origin redirect rule, discovery's
SSRF guard tightened rather than left unchanged, and the rate cap
dropped on purpose;
- branding moved to #103/#200 and keyed on the validated redirect. A
client_id-domain key would have let any local program pose as a
loopback CIMD client of a vetted vendor;
- the DoS residuals as built, the tests that pin the behaviour, and an
index of where it lives.
Section numbers 3.4 and 3.5, which the code cites, keep their meaning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aterga
changed the title
docs: scope CIMD to replace open DCR (trust-policy-gated)
docs: CIMD design record (trust-policy-gated, additive; shipped in #191 and #203)
Oct 2, 2026
This record is marked implemented, but the reviewed tree contains none of the implementation it documents: McpConfig has no cimd_enabled field (src/lib.rs:115-143), validate_client only consults the DCR store (src/auth.rs:829-836), and the AS metadata does not advertise CIMD (src/auth.rs:2179-2194). The referenced crates/imcp2-core/src/public_fetch.rs is also absent. Please merge/rebase the #191/#203 implementation into this PR's base before publishing this as an implemented design record, or keep the document framed as a plan until that code is present.
Brings in #191 and #203, the implementation this record documents, so the
PR's tree contains the code it cites.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
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.
Summary
This turns the CIMD scoping plan (
docs/scoping-cimd.md) into a design record of what shipped. CIMD landed in #191 (opt-in) and #203 (on by default, withOAUTH_CIMD_ENABLEDas a kill switch), and is live in production. The CIMD code cites this document ("the trust policy of PR #143", "§3.4", "§3.5"), so merging it gives those references a home in the tree. Sections 3.4 and 3.5 keep their meaning.What the record covers
client_idshape rules and the trust gate;imcp2_core::public_fetch);OAUTH_CIMD_ENABLED=0and redeploy; a restart alone isn't enough).issinteraction.ETagrevalidation;client_id(e.g. Claude Code's) and be shown as the vendor. Surface vetted-connector branding to Internet Identity, bound to the authorization session #200 isn't merged yet, and nothing is displayed until Internet Identity renders it.The record was adversarially checked against
main(8397307) before this push. 25 of 26 findings held up, and all 25 are applied.Testing
Docs-only; there is no build or test impact.
🤖 Generated with Claude Code