Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a5cf0ec
Support Client ID Metadata Documents (CIMD) as a registration mode
aterga Sep 3, 2026
c776135
Report CIMD advertisement on the status dashboard
aterga Sep 3, 2026
6d6124a
Use the scanner's canonical private address in the SSRF-guard test
aterga Sep 3, 2026
d954433
Tighten the CIMD fetch and cache as review found
aterga Sep 3, 2026
51aed76
Make the fetch deadline the only timeout
aterga Sep 3, 2026
bc39d88
Harden the CIMD fetch path as the second review round found
aterga Sep 3, 2026
87f3ea7
Gate CIMD on the vendor trust policy and make it opt-in
claude Sep 3, 2026
49322bc
Close the fourth review round's gaps in the CIMD fetch path
claude Sep 3, 2026
6b294d1
Read Cache-Control as the shared cache this is, and tighten two edges
claude Sep 3, 2026
c851781
Bound the CIMD client_id and key per-host slots by one host spelling
claude Sep 3, 2026
bedecb8
Require a metadata document to name the authorization-code flow
claude Sep 3, 2026
efe8443
Retire a CIMD flight when the request fetching for it is dropped
claude Sep 3, 2026
bfad6cf
Refuse deprecated IPv6 site-local addresses in the SSRF guard
claude Sep 3, 2026
95c5ebc
Bound the CIMD fetch rate and keep one flight through a cancelled fet…
claude Sep 3, 2026
419b9df
Count a response's apparent age, spend rate tokens only on a fetch
claude Sep 3, 2026
76bedff
Retire a published CIMD flight at once; refuse two more IPv6 ranges
claude Sep 3, 2026
2a3cf1b
Say that the CIMD rollback needs a redeploy, not just the variable
claude Sep 3, 2026
23b5f67
Default-deny 2001::/23, treat a malformed max-age as stale, retire be…
claude Sep 3, 2026
efc71c4
Parse Cache-Control quoted-strings, take the client_id as given, admi…
claude Sep 3, 2026
9a91292
Honour Expires, accept an upper-case scheme, fix a test's contract
claude Sep 3, 2026
dbbf817
Refuse the 6to4 relay block, Vary: *, and a client_id the parser woul…
claude Sep 3, 2026
c1e2611
Saturate an unparseable Age, and log CIMD failures only where a fetch…
claude Sep 3, 2026
8a06f94
Bring two CIMD doc comments up to date with the negative cache
claude Sep 3, 2026
cfddaa5
Describe PublicDocument::cache_max_age as freshness computes it
claude Sep 3, 2026
f5cc269
Fold every Age line conservatively; refuse a client_id the parser wou…
claude Sep 3, 2026
bafdcaf
Default-deny native IPv6 outside 2000::/3 in the SSRF guard
claude Sep 3, 2026
bed9cf6
Accept only 200 OK as the document; read an undecodable Cache-Control…
claude Sep 3, 2026
445afa7
Apply a response's age to the default CIMD cache lifetime too
claude Sep 3, 2026
64007a9
Refuse a CIMD client_id containing a backslash
claude Sep 3, 2026
bca1f99
Treat 421 and 425 as failures of the moment, and test the retry response
claude Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,25 @@ its AS issuer is `<PUBLIC_URL>/mcp` and everything OAuth lives under it:
honoured (intersected with `authorization_code`). A **hosted** `redirect_uri` is
rejected unless its host is on the allow-list (see the Companion-control note
below); loopback redirects are always accepted.
- **Client ID Metadata Documents** — the MCP authorization spec's preferred
registration, advertised as `client_id_metadata_document_supported: true`. A
client may skip `/register` and use the https URL of its metadata document as
its `client_id`; `/mcp/oauth/authorize` fetches that document under the same
SSRF guard as app discovery (https only, public addresses only, pinned, no
redirects, 8 KiB cap, 5 s including DNS), requires its `client_id` to equal the URL, and checks the requested
redirect against the document's `redirect_uris` exactly as it would a DCR
registration's — hosted-redirect allow-list included, and checked before any
fetch, so a document can neither admit a redirect a DCR client couldn't
register nor make the server fetch a URL for a redirect it would refuse. A
hosted redirect must also be same-origin with the document URL (loopback
excepted), so a self-asserted document cannot point the code at another
party. Only public clients (`token_endpoint_auth_method: none`) are accepted.
Documents are cached (bounded; the origin's `max-age` honoured up to 24 h,
10 min when it sends none, not at all on `no-store`), so a directory client
connecting thousands of times mints no registrations. Claude and ChatGPT both select CIMD over DCR when it is
advertised; `OAUTH_CIMD_DISABLED=1` withdraws the advertisement and the
mechanism without a rebuild (clients re-read the metadata within minutes and
fall back to DCR).

- `GET /mcp/oauth/authorize` — validates the client + redirect, requires PKCE, sets
the binding cookie, then redirects to II's handshake (with `registration_key`)
Expand Down
2 changes: 1 addition & 1 deletion crates/imcp2-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ rmcp = { workspace = true, features = ["server", "macros"] }
ic-agent = { workspace = true }
candid = { workspace = true }
candid_parser = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "net", "time"] }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
Expand Down
4 changes: 2 additions & 2 deletions crates/imcp2-core/src/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1380,7 +1380,7 @@ fn ipv6_is_global(ip: &Ipv6Addr) -> bool {
/// Validate a user-supplied discovery URL against SSRF and return the parsed URL
/// plus the socket addresses to PIN the client to. https only; every resolved
/// address must be global. Async DNS (no blocking of the executor).
async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec<SocketAddr>), String> {
pub(crate) async fn resolve_public_url(raw: &str) -> Result<(url::Url, Vec<SocketAddr>), String> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid discovery URL {raw}: {e}"))?;
if url.scheme() != "https" {
return Err(format!(
Expand Down Expand Up @@ -1536,7 +1536,7 @@ enum Overflow {
/// The shared read. `Err((partial, error))` carries what had arrived before the
/// transfer failed, so the fail-soft caller can keep it and the strict one can
/// report the failure.
async fn read_capped_inner(
pub(crate) async fn read_capped_inner(
mut resp: reqwest::Response,
max: usize,
) -> Result<String, (String, String)> {
Expand Down
1 change: 1 addition & 0 deletions crates/imcp2-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

pub mod identities;
pub mod iiconnect;
pub mod public_fetch;
pub mod skills;
pub mod tools;

Expand Down
180 changes: 180 additions & 0 deletions crates/imcp2-core/src/public_fetch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! One SSRF-guarded GET of a small public document, for callers outside the
//! discovery crawl that must fetch a URL a stranger handed them. Today that is
//! the hosted OAuth authorization server, fetching a client's *Client ID Metadata
//! Document* — the MCP authorization spec's preferred registration, where the
//! `client_id` an unauthenticated `/oauth/authorize` request carries IS an https
//! URL and the JSON at that URL is the client's registration.
//!
//! The guard is the discovery module's (CWE-918): https only; the host resolved
//! up front and refused if ANY address is loopback / private / link-local /
//! CGNAT / otherwise reserved; the validated addresses pinned into the client so
//! a re-resolution cannot rebind the connection (DNS rebinding); and the body
//! read under a hard byte cap (CWE-770). On top of that, this fetch is STRICT
//! where the crawl is opportunistic — the document is the URL's own statement
//! about itself, so:
//!
//! * redirects are not followed at all: a 3xx is a non-success answer, so no
//! other URL's bytes — on another host, another port, or another path of the
//! same origin — can ever stand in for the document at this one;
//! * a body over the cap, or one whose transfer failed part-way, is an error,
//! never a shorter document;
//! * the caller's timeout is ONE deadline over the whole operation, DNS
//! resolution included, so a slow resolver cannot hold the caller past it —
//! and it is the only deadline, so however far the fetch got when it ran out
//! of time, the caller sees the same "did not complete" error.

use std::time::Duration;

use crate::discover::{read_capped_inner, resolve_public_url};

/// A small public document fetched under the SSRF guard.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicDocument {
/// The complete body (it fit under the caller's cap).
pub body: String,
/// The `Content-Type` the origin sent, if any.
pub content_type: Option<String>,
/// The `max-age` of the origin's `Cache-Control`, if it sent one; `Some(0)`
/// when it said `no-store` or `no-cache`. A hint for the caller's own cache,
/// for the caller to bound — never binding.
pub cache_max_age: Option<Duration>,
}

/// GET `url` and return its body, or the reason it was not fetched: the URL is
/// refused by the SSRF guard (not https, no host, or a host with a non-public
/// address); resolving, connecting, answering and delivering the body did not
/// all complete within `timeout`; the answer was anything but 2xx (a redirect
/// included); the body is larger than `max_bytes`; or the transfer was cut off.
pub async fn fetch_public_document(
url: &str,
max_bytes: usize,
timeout: Duration,
) -> Result<PublicDocument, String> {
// One deadline over everything, resolution included: `resolve_public_url`
// does the DNS lookup, and a resolver that never answers must not hold the
// caller (and whatever it is holding, such as an in-flight permit) forever.
// Deliberately the ONLY deadline — the client below sets none of its own —
// so the error is the same wherever the time ran out, and dropping the
// future on expiry is what aborts the connection.
tokio::time::timeout(timeout, fetch(url, max_bytes))
.await
.map_err(|_| format!("fetching {url} did not complete within {timeout:?}"))?
}

async fn fetch(url: &str, max_bytes: usize) -> Result<PublicDocument, String> {
let (parsed, pinned) = resolve_public_url(url).await?;
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
let client = reqwest::Client::builder()
.user_agent(concat!("imcp2-core/", env!("CARGO_PKG_VERSION")))
// Never follow a redirect: the document is this URL's statement about
// itself, and a 3xx is that URL declining to make it. Refusing here (rather
// than following under the crawl's redirect guard and comparing origins
// afterwards) also closes the same-origin case, where a redirect to another
// path would have put a different document behind this URL.
.redirect(reqwest::redirect::Policy::none())
.resolve_to_addrs(&host, &pinned)
Comment thread
aterga marked this conversation as resolved.
.build()
.map_err(|e| format!("http client: {e}"))?;
let resp = client
.get(parsed.as_str())
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await
.map_err(|e| format!("could not fetch {url}: {e}"))?;
let status = resp.status();
if !status.is_success() {
Comment thread
aterga marked this conversation as resolved.
Outdated
return Err(format!("{url} answered {status}"));
}
let header = |name: reqwest::header::HeaderName| {
resp.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned)
};
let content_type = header(reqwest::header::CONTENT_TYPE);
let cache_max_age = header(reqwest::header::CACHE_CONTROL).and_then(|v| cache_max_age(&v));
// Read ONE byte past the cap so overflow is detectable: a truncated body is
// not a shorter document. Saturating, so a caller passing `usize::MAX` (no
// cap) reads everything rather than wrapping to a zero-byte read.
let body = match read_capped_inner(resp, max_bytes.saturating_add(1)).await {
Ok(body) if body.len() > max_bytes => {
return Err(format!("{url} is larger than the {max_bytes}-byte cap"))
}
Ok(body) => body,
Err((_, e)) => return Err(format!("reading {url} failed part-way: {e}")),
};
Ok(PublicDocument { body, content_type, cache_max_age })
}

/// The caching lifetime a `Cache-Control` value asks for: its `max-age`, or zero
/// when it forbids reuse (`no-store` / `no-cache`); `None` when it says neither.
fn cache_max_age(cache_control: &str) -> Option<Duration> {
let directives: Vec<&str> = cache_control.split(',').map(str::trim).collect();
if directives
.iter()
.any(|d| d.eq_ignore_ascii_case("no-store") || d.eq_ignore_ascii_case("no-cache"))
{
return Some(Duration::ZERO);
}
directives.iter().find_map(|d| {
let (name, value) = d.split_once('=')?;
name.trim()
.eq_ignore_ascii_case("max-age")
.then(|| value.trim().trim_matches('"').parse::<u64>().ok())?
.map(Duration::from_secs)
})
Comment thread
aterga marked this conversation as resolved.
Outdated
}

#[cfg(test)]
mod tests {
use std::time::Duration;

use super::{cache_max_age, fetch_public_document};

/// The SSRF guard decides before any request: these never touch the network
/// (IP-literal hosts need no DNS), and each is refused for the reason the
/// guard names.
#[tokio::test]
async fn guard_refuses_before_fetching() {
let fetch = |url: &'static str| fetch_public_document(url, 1024, Duration::from_secs(1));
assert!(fetch("http://example.com/client.json").await.unwrap_err().contains("only https"));
for internal in [
"https://127.0.0.1/client.json",
"https://10.0.0.1/client.json",
"https://192.168.1.1/client.json",
"https://169.254.169.254/latest/meta-data/",
"https://[::1]/client.json",
"https://[::ffff:127.0.0.1]/client.json",
] {
let err = fetch(internal).await.unwrap_err();
assert!(err.contains("non-public address"), "{internal}: {err}");
}
assert!(fetch("not a url").await.is_err());
// An uncapped read is a valid request, not an overflow.
let uncapped = fetch_public_document("https://[::1]/x", usize::MAX, Duration::from_secs(1));
assert!(uncapped.await.unwrap_err().contains("non-public address"));
}

/// One deadline over the whole fetch: with no time at all, the operation fails
/// with the deadline's error whether it ran out during DNS resolution or after
/// (on a fast resolver, during the connect) — never with a request error of its
/// own, since the client sets no separate timeout.
#[tokio::test]
async fn one_deadline_covers_the_whole_fetch() {
let err = fetch_public_document("https://example.com/client.json", 1024, Duration::ZERO)
.await
.unwrap_err();
assert!(err.contains("did not complete within"), "{err}");
}

#[test]
fn cache_control_lifetime() {
assert_eq!(cache_max_age("max-age=300"), Some(Duration::from_secs(300)));
assert_eq!(
cache_max_age("public, max-age=86400, immutable"),
Some(Duration::from_secs(86400))
);
assert_eq!(cache_max_age("Max-Age=\"60\""), Some(Duration::from_secs(60)));
assert_eq!(cache_max_age("no-store"), Some(Duration::ZERO));
assert_eq!(cache_max_age("max-age=300, no-cache"), Some(Duration::ZERO));
assert_eq!(cache_max_age("public"), None);
assert_eq!(cache_max_age("max-age=soon"), None);
}
}
12 changes: 7 additions & 5 deletions docs/anthropic-directory-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ submission — and match a live scan of a deployed instance of that build
| HTTPS remote server, Streamable HTTP transport | ✅ `rmcp` streamable-HTTP, stateless, JSON responses ([`src/lib.rs`](../src/lib.rs)) |
| OAuth 2.0, authorization-code + PKCE **S256**, advertised in metadata | ✅ `code_challenge_methods_supported: ["S256"]` in the live RFC 8414 document |
| Dynamic Client Registration (RFC 7591) — the out-of-the-box `oauth_dcr` mode | ✅ live probe: `POST /mcp/oauth/register` with the claude.ai callback → `201` |
| Client ID Metadata Documents — the `oauth_cimd` mode Anthropic recommends over DCR for directory listings | ✅ `client_id_metadata_document_supported: true` alongside `"none"` in `token_endpoint_auth_methods_supported`, the two flags Claude requires to select CIMD. Claude Code's live document (`https://claude.ai/oauth/claude-code-client-metadata`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs), `cimd_client_id` / `parse_client_metadata`) |
| Claude's hosted callback `https://claude.ai/api/mcp/auth_callback` accepted | ✅ seeded in the redirect allow-list ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) |
| Claude Code loopback redirects (RFC 8252) | ✅ loopback redirects are exempt from the hosted allow-list |
| Discovery documents (RFC 8414 + RFC 9728, path-scoped + root fallback) | ✅ all four live, `WWW-Authenticate` on the 401 points at the resource metadata |
Expand All @@ -75,11 +76,12 @@ submission — and match a live scan of a deployed instance of that build

Notes on auth mode: pure M2M `client_credentials` is unsupported by Claude
(every connection needs a user in the loop) — IMCP2's user-consent flow via
Internet Identity is exactly the supported shape. Claude registers a new DCR
client on each fresh connection; the server's registration store is a bounded
LRU of 10,000, which tolerates that churn, but Anthropic recommends **CIMD**
(Client ID Metadata Documents) for high-traffic directory listings — worth
considering as a follow-up if usage grows.
Internet Identity is exactly the supported shape. Against a DCR-only server
Claude registers a new client on each fresh connection (the registration store
is a bounded LRU of 10,000, which tolerates that churn); Anthropic recommends
**CIMD** (Client ID Metadata Documents) for high-traffic directory listings,
and the server now advertises and implements it, so Claude selects CIMD and
registers nothing.

## Blockers to resolve before submitting

Expand Down
2 changes: 1 addition & 1 deletion docs/openai-directory-submission.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ add details not published in the docs.
| Requirement | Status |
|---|---|
| OAuth 2.1 authorization-code + PKCE **S256**, per the MCP authorization spec | ✅ live; `code_challenge_methods_supported: ["S256"]` |
| Client registration: DCR (`registration_endpoint`) — CIMD and predefined clients also accepted | ✅ RFC 7591 DCR live and verified |
| Client registration: CIMD preferred; DCR (`registration_endpoint`) and predefined clients also accepted | ✅ both. `client_id_metadata_document_supported: true` — ChatGPT's live document (`https://chatgpt.com/oauth/client.json`) is a fixture of the parsing test ([`src/auth.rs`](../src/auth.rs)); it prefers `private_key_jwt` but lists `none`, which is what it uses against this AS — and RFC 7591 DCR live and verified |
| Discovery documents (RFC 8414 AS metadata + RFC 9728 protected-resource) | ✅ all live, path-scoped + root fallback |
| Both of ChatGPT's callbacks accepted — `https://chatgpt.com/connector_platform_oauth_redirect` (the form it sends us) and `https://chatgpt.com/connector/oauth/{callback_id}` | ✅ the redirect allow-list pins both paths for `chatgpt.com` ([`src/auth.rs`](../src/auth.rs), `DEFAULT_ALLOWED_REDIRECTS`) |
| No machine-to-machine grants (client credentials etc. unsupported by ChatGPT) | ✅ user-consent authorization-code flow only |
Expand Down
4 changes: 2 additions & 2 deletions monitoring/mcp-status/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ export const checkMcpEndpoints = async (
id: "as-metadata",
label: "OAuth Authorization Server Metadata",
description:
"Verifies the RFC 8414 metadata advertising the authorize/token/registration endpoints and PKCE support that clients need to log in.",
"Verifies the RFC 8414 metadata advertising the authorize/token/registration endpoints and PKCE support that clients need to log in, and reports whether Client ID Metadata Documents are advertised (the registration mode Claude and ChatGPT prefer over DCR; off when the server runs with OAUTH_CIMD_DISABLED).",
target: `GET ${url}`,
expected: "200 JSON with issuer + authorize/token/register endpoints",
status: pass ? "pass" : "fail",
Expand All @@ -425,7 +425,7 @@ export const checkMcpEndpoints = async (
? r.error
? `request failed: ${r.error.message}`
: `${r.status}${redirectNote(r)}, missing fields: ${missing.join(", ") || "n/a"}`
: `issuer=${asMeta.issuer}, PKCE=${(asMeta.code_challenge_methods_supported || []).join(",") || "none"}`,
: `issuer=${asMeta.issuer}, PKCE=${(asMeta.code_challenge_methods_supported || []).join(",") || "none"}, CIMD=${asMeta.client_id_metadata_document_supported === true ? "on" : "off"}`,
});
}

Expand Down
4 changes: 4 additions & 0 deletions monitoring/mcp-status/checks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const healthyRoutes = (origin) => ({
token_endpoint: `${origin}/mcp/oauth/token`,
registration_endpoint: `${origin}/mcp/oauth/register`,
code_challenge_methods_supported: ["S256"],
client_id_metadata_document_supported: true,
}),
}),
[`POST ${origin}/mcp`]: resp(401, {
Expand Down Expand Up @@ -422,6 +423,9 @@ test("checkMcpEndpoints passes for a well-behaved server", async () => {
assert.equal(byId(section, "root").status, "pass");
assert.equal(byId(section, "protected-resource").status, "pass");
assert.equal(byId(section, "as-metadata").status, "pass");
// The detail line reports whether CIMD is advertised, so the kill switch's
// effect (or a regression that drops the flag) is visible on the dashboard.
assert.match(byId(section, "as-metadata").detail, /, CIMD=on$/);
assert.equal(byId(section, "metadata-consistency").status, "pass");
assert.equal(byId(section, "mcp-challenge").status, "pass");
assert.equal(byId(section, "oauth-register").status, "pass");
Expand Down
Loading
Loading