Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .github/workflows/deploy-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ jobs:
# Unset -> endpoint 404s. An optional secret that neither caller
# passes arrives here as the empty string.
OPENAI_APPS_CHALLENGE_TOKEN: ${{ secrets.OPENAI_APPS_CHALLENGE_TOKEN }}
# Client ID Metadata Documents: advertised only where this GitHub
# Environment defines the OAUTH_CIMD_ENABLED variable as `1` (this job
# runs in that environment, so its variables resolve here). Unset
# arrives as the empty string, which the server reads as off.
OAUTH_CIMD_ENABLED: ${{ vars.OAUTH_CIMD_ENABLED }}
run: deploy/native/deploy.sh

# Confirm the host is actually running the commit we just shipped, rather
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ dist = false
# codec, and candid lines can never drift between imcp2-core, imcp2, and
# imcp2-local.
[workspace.dependencies]
httpdate = "1.0.3"
imcp2-core = { path = "crates/imcp2-core", version = "0.4.0" }
rmcp = "1.7"
axum = "0.8"
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,43 @@ 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 clients that can authenticate as PUBLIC clients are accepted: a
`token_endpoint_auth_method` of `none` (or none given), or `none` among the
document's `token_endpoint_auth_methods_supported` — ChatGPT's case, since it
prefers `private_key_jwt` but lists `none`, which is what it uses here.
Documents must be served as `application/json`, and are cached (bounded; the
origin's remaining freshness — `max-age` less `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. Concurrent requests for
one document share a single fetch, and at most eight fetches are in flight at
once per process (however many instances the binary mounts), two per host, so
one slow host cannot hold up the rest — and no more than sixty a minute per
process, thirty per vendor domain, so an origin that answers at once cannot be
made to answer without end. The fetch connects directly, never
through a proxy from the environment, so the address pin always binds. Claude and ChatGPT both select CIMD over DCR when it is
advertised — which it is only where `OAUTH_CIMD_ENABLED=1` is set (the deploy
template takes it from the GitHub Environment's variable of that name, so a
deploy never enables it by itself; to roll back, unset it and redeploy — the
value is read once at start-up, so the variable alone changes nothing — and
clients re-read the metadata within minutes and fall back to DCR). Only a
document on a vetted vendor origin is fetched at all — a host on or under an
allow-listed domain, default port (the trust policy of PR #143); any other URL
`client_id` is refused before any request and pointed at the allow-listing
contact. A document-intrinsic failure (no document there, not JSON, about
another URL) is remembered for a minute so a repeat is cheap; a transient one
is not.

- `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
8 changes: 7 additions & 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 All @@ -31,6 +31,7 @@ reqwest = { workspace = true }
getrandom = { workspace = true }
urlencoding = { workspace = true }
url = { workspace = true }
httpdate = { workspace = true }
# Only for the end-to-end canister-tool tests (src/e2e_canister_tools.rs),
# behind the `e2e` feature so the default build compiles neither. `pocket-ic`
# runs a real replica locally (needs the POCKET_IC_BIN server binary at
Expand All @@ -48,3 +49,8 @@ wat = { version = "1", optional = true }
# `Identities::seed_app_identity` — are gated on `cfg(test)` as well, so they
# exist only in this crate's own test binary, never in a library build.
e2e = ["dep:pocket-ic", "dep:wat", "rmcp/client"]

[dev-dependencies]
# Synthetic responses for pinning `public_fetch`'s acceptance rules without a
# network (`reqwest::Response: From<http::Response<_>>`).
http = { workspace = true }
151 changes: 125 additions & 26 deletions crates/imcp2-core/src/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1350,6 +1350,9 @@ fn ipv4_is_global(ip: &Ipv4Addr) -> bool {
|| (o[0] == 100 && (o[1] & 0xc0) == 64) // 100.64.0.0/10 CGNAT (shared)
|| (o[0] == 192 && o[1] == 0 && o[2] == 0) // 192.0.0.0/24 IETF protocol
|| (o[0] == 198 && (o[1] & 0xfe) == 18) // 198.18.0.0/15 benchmarking
// 192.88.99.0/24, the deprecated 6to4 relay anycast block (RFC 7526), is not
// globally reachable — bar 192.88.99.2, the 6a44 relay anycast (RFC 6751).
|| (o[0] == 192 && o[1] == 88 && o[2] == 99 && o[3] != 2)
|| o[0] >= 240) // 240.0.0.0/4 reserved
}

Expand All @@ -1362,50 +1365,108 @@ fn ipv6_is_global(ip: &Ipv6Addr) -> bool {
return ipv4_is_global(&v4);
}
let seg = ip.segments();
!(ip.is_unspecified() // ::
|| ip.is_loopback() // ::1
|| ip.is_multicast() // ff00::/8
|| (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique-local
|| (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local unicast
|| (seg[0] == 0x2001 && seg[1] == 0x0db8) // 2001:db8::/32 documentation
// Transition mechanisms embed an IPv4 address deeper in the v6 space than
// `to_ipv4` decodes, so a NAT64/6to4/Teredo host would otherwise translate
// one of these to loopback/link-local/RFC1918/metadata (ICPBB-377). imcp2
// never needs to reach them, so refuse the prefixes outright.
|| (seg[0] == 0x0064 && seg[1] == 0xff9b) // 64:ff9b::/32 NAT64 (RFC 6052 WKP + RFC 8215 local-use)
|| seg[0] == 0x2002 // 2002::/16 6to4
|| (seg[0] == 0x2001 && seg[1] == 0x0000)) // 2001::/32 Teredo
// DEFAULT-DENY: IANA allocates global unicast only from 2000::/3 (RFC 4291
// §2.5.4; RFC 3513 §2.5.6), so a native address outside it — `::`, `::1`,
// the discard-only 100::/64, the NAT64 well-known 64:ff9b::/32, the SRv6
// 5f00::/16, unique-local fc00::/7, link-local fe80::/10, the deprecated
// site-local fec0::/10, multicast ff00::/8, and everything unallocated in
// between (4000::1 is nobody's) — is refused without being named, and so is
// whatever IANA allocates next, until it is audited here.
if (seg[0] & 0xe000) != 0x2000 {
return false;
}
// Within 2000::/3, 2001::/23 is IETF protocol assignments (RFC 2928): NOT
// globally reachable by default — Teredo (2001::/32), benchmarking
// (2001:2::/48), ORCHID and ORCHIDv2 (2001:10::/28, 2001:20::/28), and
// everything unassigned — with the IANA registry's globally reachable
// exceptions admitted by name, so a new assignment is refused until audited
// rather than accepted until noticed.
if seg[0] == 0x2001 && (seg[1] & 0xfe00) == 0 {
return ietf_protocol_assignment_is_global(&seg);
}
// The rest of 2000::/3 is global unicast, less its special-purpose carve-outs.
!((seg[0] == 0x2001 && seg[1] == 0x0db8) // 2001:db8::/32 documentation
|| (seg[0] == 0x3fff && (seg[1] & 0xf000) == 0) // 3fff::/20 documentation (RFC 9637)
// 6to4 embeds an IPv4 address deeper in the v6 space than `to_ipv4`
// decodes, so a 6to4 host would otherwise translate one of these to
// loopback/link-local/RFC1918/metadata (ICPBB-377); NAT64 and Teredo are
// refused above. imcp2 never needs to reach them, so refuse the prefix.
|| seg[0] == 0x2002) // 2002::/16 6to4
}

/// The globally reachable exceptions inside `2001::/23` (IETF protocol
/// assignments), per the IANA IPv6 Special-Purpose Address Registry. Everything
/// else in the block — assigned to a non-routable use or not assigned at all —
/// is refused.
fn ietf_protocol_assignment_is_global(seg: &[u16; 8]) -> bool {
let anycast = seg[1] == 0x0001 && seg[2..7] == [0; 5] && (1..=3).contains(&seg[7]);
anycast // 2001:1::1 PCP (RFC 7723), 2001:1::2 TURN (RFC 8155), 2001:1::3 DNS-SD SRP (RFC 9665)
|| seg[1] == 0x0003 // 2001:3::/32 AMT (RFC 7450)
|| (seg[1] == 0x0004 && seg[2] == 0x0112) // 2001:4:112::/48 AS112-v6 (RFC 7535)
|| (seg[1] & 0xfff0) == 0x0030 // 2001:30::/28 Drone Remote ID (RFC 9374)
}

/// Why [`resolve_public_url`] returned no addresses: the URL itself is refused —
/// it does not parse, is not https, names no host, or resolves to a non-public
/// address (the SSRF guard) — or its host could not be resolved RIGHT NOW, which
/// says nothing about the URL. A caller that remembers refusals (the OAuth
/// server's client-metadata cache) must not remember a resolver outage as one.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ResolveError {
Refused(String),
Unresolved(String),
}

impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Refused(why) | Self::Unresolved(why) => f.write_str(why),
}
}
}

/// The discovery crawl reports every failure as a message, and tells these apart
/// no further; `?` keeps working there.
impl From<ResolveError> for String {
fn from(err: ResolveError) -> Self {
match err {
ResolveError::Refused(why) | ResolveError::Unresolved(why) => why,
}
}
}

/// 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> {
let url = url::Url::parse(raw).map_err(|e| format!("invalid discovery URL {raw}: {e}"))?;
pub(crate) async fn resolve_public_url(
raw: &str,
) -> Result<(url::Url, Vec<SocketAddr>), ResolveError> {
let url = url::Url::parse(raw)
.map_err(|e| ResolveError::Refused(format!("invalid discovery URL {raw}: {e}")))?;
if url.scheme() != "https" {
return Err(format!(
return Err(ResolveError::Refused(format!(
"refusing to fetch {raw}: only https:// discovery targets are allowed (SSRF guard)"
));
)));
}
let port = url.port_or_known_default().unwrap_or(443);
let addrs: Vec<SocketAddr> = match url.host() {
Some(url::Host::Ipv4(v4)) => vec![SocketAddr::new(IpAddr::V4(v4), port)],
Some(url::Host::Ipv6(v6)) => vec![SocketAddr::new(IpAddr::V6(v6), port)],
Some(url::Host::Domain(host)) => tokio::net::lookup_host((host, port))
.await
.map_err(|e| format!("could not resolve {host}: {e}"))?
.map_err(|e| ResolveError::Unresolved(format!("could not resolve {host}: {e}")))?
.collect(),
None => return Err(format!("refusing to fetch {raw}: no host")),
None => return Err(ResolveError::Refused(format!("refusing to fetch {raw}: no host"))),
};
if addrs.is_empty() {
return Err(format!("refusing to fetch {raw}: host did not resolve"));
return Err(ResolveError::Unresolved(format!("could not resolve {raw}: no addresses")));
}
if let Some(bad) = addrs.iter().find(|a| !ip_is_global(&a.ip())) {
return Err(format!(
return Err(ResolveError::Refused(format!(
"refusing to fetch {raw}: it resolves to a non-public address ({}) — discovery is \
restricted to public hosts (SSRF guard)",
bad.ip()
));
)));
}
Ok((url, addrs))
}
Expand Down Expand Up @@ -1536,10 +1597,26 @@ 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(
mut resp: reqwest::Response,
pub(crate) async fn read_capped_inner(
resp: reqwest::Response,
max: usize,
) -> Result<String, (String, String)> {
// Lossy by design for the crawl: a stray byte must not cost a whole bundle.
// A caller that treats the body as a statement (the CIMD fetch) reads the
// bytes and decodes strictly instead.
match read_capped_bytes(resp, max).await {
Ok(buf) => Ok(String::from_utf8_lossy(&buf).into_owned()),
Err((buf, e)) => Err((String::from_utf8_lossy(&buf).into_owned(), e)),
}
}

/// The shared read in bytes: up to `max` of the body, stopping — and so dropping
/// the response and its connection — at the cap. `Err((partial, error))` carries
/// what had arrived before the transfer failed.
pub(crate) async fn read_capped_bytes(
mut resp: reqwest::Response,
max: usize,
) -> Result<Vec<u8>, (Vec<u8>, String)> {
let mut buf: Vec<u8> = Vec::new();
loop {
if buf.len() >= max {
Expand All @@ -1554,10 +1631,10 @@ async fn read_capped_inner(
}
}
Ok(None) => break,
Err(e) => return Err((String::from_utf8_lossy(&buf).into_owned(), e.to_string())),
Err(e) => return Err((buf, e.to_string())),
}
}
Ok(String::from_utf8_lossy(&buf).into_owned())
Ok(buf)
}

/// GET `url` and return up to `max` bytes of its body, distinguishing "this app
Expand Down Expand Up @@ -2794,6 +2871,7 @@ mod tests {
// Publicly-routable addresses.
assert!(g("8.8.8.8"));
assert!(g("1.1.1.1"));
assert!(g("192.88.99.2"), "the 6a44 relay anycast is the reachable exception in its /24");
assert!(g("2606:4700:4700::1111"));
// Loopback / private / link-local / CGNAT / reserved / doc / bench, plus
// IPv4 embedded in IPv6 as MAPPED (::ffff:…) and COMPATIBLE (::…) forms.
Expand All @@ -2808,12 +2886,26 @@ mod tests {
"255.255.255.255",
"192.0.2.1",
"198.18.0.1",
"192.88.99.1", // 6to4 relay anycast, deprecated
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"fec0::1", // site-local: deprecated, still routable on legacy networks
"100::1", // discard-only
"2001:2::1", // benchmarking
"2001:10::1", // ORCHID (deprecated)
"2001:20::1", // ORCHIDv2 (not routable)
"2001:1::4", // unassigned inside 2001::/23 (IETF protocol assignments)
"2001:5::1", // likewise
"2001:1ff::1", // the block's last /32, likewise
"3fff::1", // documentation (RFC 9637)
"5f00::1", // SRv6 SIDs (RFC 9602)
"4000::1", // outside 2000::/3: not allocated for global unicast
"8000::1", // likewise
"e000::1", // likewise
"2001:db8::1",
"::ffff:127.0.0.1",
"::ffff:10.0.0.1", // IPv4-mapped private/loopback
Expand All @@ -2833,6 +2925,13 @@ mod tests {
}
// A real public v6 that merely starts with 0x2001 (not db8/Teredo) stays global.
assert!(g("2001:4860:4860::8888"));
assert!(g("2001:200::1"), "just past 2001::/23");
// The globally reachable exceptions inside 2001::/23 stay global.
for good in
["2001:1::1", "2001:1::2", "2001:1::3", "2001:3::1", "2001:4:112::1", "2001:30::1"]
{
assert!(g(good), "{good} is globally reachable per the IANA registry");
}
}

// The exact vectors from the finding, plus https-to-internal, are refused
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
Loading
Loading