Add CIMD document fetch/validate and extend SSRF protections#5320
Conversation
Phase 2 PR 1 of CIMD embedded AS support (issue #4825). - pkg/oauthproto/cimd.go: add ClientMetadataDocument struct, FetchClientMetadataDocument (HTTPS-only, 10 KB cap, 5 s timeout, 1-hop redirect limit, per-dial SSRF check, Content-Type validation, strict self-referential binding), ValidateClientMetadataDocument. SSRF check implemented inline to preserve the oauthproto leaf-package invariant (no import of pkg/networking). - pkg/networking/utilities.go: add RFC6598 CGN (100.64.0.0/10) and RFC5737 documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) to the private IP block list. - pkg/networking/http_client.go: add WithDisableKeepAlives option to HttpClientBuilder so callers can prevent keep-alive connection reuse from bypassing per-dial SSRF checks. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5320 +/- ##
==========================================
- Coverage 68.41% 68.40% -0.02%
==========================================
Files 621 622 +1
Lines 63278 63371 +93
==========================================
+ Hits 43293 43349 +56
- Misses 16757 16784 +27
- Partials 3228 3238 +10 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Verified against both draft-ietf-oauth-client-id-metadata-document and internal RFC-0071: - validateCIMDClientURL enforces §3 URL requirements: non-empty path component, no fragment, no userinfo, no dot-segments; removes the TODO that was deferring this validation to Phase 2 - ValidateClientMetadataDocument rejects symmetric auth methods (client_secret_post, client_secret_basic, client_secret_jwt) per §4.1 of the CIMD draft - Add IPv4 multicast (224.0.0.0/4) and IPv6 multicast (ff00::/8) to both pkg/networking and the oauthproto inline SSRF block list - Update tests to use meaningful URL paths (/metadata.json); bare-root paths (/) now correctly fail the §3 path requirement Note: custom CA cert support (RFC-0071 §4 server-side) is deferred to PR 2 — FetchClientMetadataDocument will accept an optional *http.Client allowing the storage decorator to pass a CA-aware client. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds foundational library support for Client ID Metadata Documents (CIMD) by introducing fetch + validation helpers in pkg/oauthproto, and extends SSRF protections in pkg/networking (additional blocked ranges + an option to disable HTTP keep-alives so per-dial SSRF checks can’t be bypassed via connection reuse).
Changes:
- Add
ClientMetadataDocumentmodel plusFetchClientMetadataDocument(HTTP fetch with guardrails) andValidateClientMetadataDocument(field + redirect URI validation). - Extend private/IP-block detection (CGN + RFC5737 documentation ranges + multicast) and add
HttpClientBuilder.WithDisableKeepAlives. - Add unit tests for CIMD fetch/validation behavior and for new private IP ranges.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/oauthproto/cimd.go | Adds CIMD document type, URL validation, SSRF-guarded fetcher, and document validation logic. |
| pkg/oauthproto/cimd_test.go | Adds tests for CIMD fetch/validation scenarios (status codes, content-type, body cap, redirect URIs, etc.). |
| pkg/networking/utilities.go | Extends private IP block list to include CGN + RFC5737 documentation ranges + multicast. |
| pkg/networking/utilities_test.go | Adds unit tests asserting the new private IP ranges are classified as private. |
| pkg/networking/http_client.go | Adds builder option to disable keep-alives (supports per-dial SSRF enforcement). |
Comments suppressed due to low confidence (3)
pkg/oauthproto/cimd.go:313
- validateRedirectURI uses string prefix checks for loopback ("http://localhost", "http://127.0.0.1", "http://[::1]"). This can accept non-loopback hosts like "http://localhost.evil.com/callback" or "http://127.0.0.1.evil.com/callback". Parse the URI and validate scheme + host (including proper IPv6 bracket handling) to ensure only true loopback HTTP or HTTPS are allowed.
func validateRedirectURI(uri string) error {
switch {
case strings.HasPrefix(uri, "https://"):
return nil
case strings.HasPrefix(uri, "http://localhost"),
strings.HasPrefix(uri, "http://127.0.0.1"),
strings.HasPrefix(uri, "http://[::1]"):
return nil
pkg/oauthproto/cimd.go:230
- Loopback addresses are explicitly allowed in the SSRF guard (
!ip.IsLoopback()), meaning an HTTPS client_id whose hostname resolves to 127.0.0.1/::1 will be permitted. This contradicts the stated goal of blocking loopback/private ranges and makes localhost SSRF possible via DNS. If loopback should only be allowed for explicit loopback development URLs, gate it on the parsed URL host being loopback/localhost rather than allowing all loopback resolutions.
// Loopback is allowed — it is already gated by the URL scheme check
// (HTTP to loopback is explicitly permitted for development). All other
// private ranges are rejected to prevent SSRF.
if !ip.IsLoopback() && isPrivateForCIMD(ip) {
return nil, fmt.Errorf("cimd: refusing connection to private address %s", ipStr)
}
pkg/oauthproto/cimd.go:189
- Using
io.LimitReader(resp.Body, maxBodyBytes)does not reliably enforce the 10KB cap: if valid JSON is fully contained within the first 10KB, the decoder will succeed and any remaining bytes will be ignored (and currently later drained). Consider limiting to maxBodyBytes+1 and returning an explicit error when the response exceeds the cap, or read with io.ReadAll using a max+1 limit.
const maxBodyBytes = 10 * 1024
limited := io.LimitReader(resp.Body, maxBodyBytes)
var doc ClientMetadataDocument
if err := json.NewDecoder(limited).Decode(&doc); err != nil {
return nil, fmt.Errorf("failed to decode client metadata document: %w", err)
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
1. isLoopbackHTTP bypass: prefix matching (e.g. "http://localhost") matched "http://localhost.evil.com/". Now parses the URL and checks the hostname exactly against loopback addresses. 2. DNS TOCTOU: DialContext resolved the hostname then dialled by name, leaving a window where the second resolution could return a private IP. Now picks the first valid IP, validates it, and dials by IP literal to eliminate the race. 3. Redirect target not validated: CheckRedirect only counted hops. A redirect from HTTPS to HTTP or to a private IP was not caught. Now calls validateCIMDClientURL on each redirect target. Also: - Drop io.Copy drain after decode; keep-alive connections are disabled so draining for connection reuse is unnecessary and would defeat the 10KB cap by reading the remainder of the body - Add clarifying comment that only symmetric auth methods are forbidden; asymmetric methods (private_key_jwt, tls_client_auth, none) are valid - Add tests: localhost subdomain bypass, dial-guard SSRF via HTTPS URL, renamed scheme-check test to accurately describe what it exercises Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
jhrozek
left a comment
There was a problem hiding this comment.
I think the code itself is good, but I think we should reduce the code duplication
Addresses jhrozek feedback on duplication:
- Create pkg/oauthproto/cimd sub-package which may import pkg/networking,
eliminating three cases of duplicated logic:
1. validateRedirectURI → now delegates to oauthproto.ValidateRedirectURI
with RedirectURIPolicyStrict (RFC 8252 §8.4)
2. isLoopbackHTTP → now uses oauthproto.IsLoopbackHost(parsed.Hostname())
via URL.Parse, closing the localhost.evil.com prefix-match bypass
3. cimdPrivateBlocks/isPrivateForCIMD → now uses networking.IsPrivateIP,
eliminating the duplicated IP block list init
- pkg/oauthproto/cimd.go retains only ToolHiveClientMetadataDocumentURL
and IsClientIDMetadataDocumentURL (Phase 1 code depends on these)
- HttpClientBuilder.WithPrivateIPs(false) is not used in newCIMDHTTPClient
because it also blocks loopback (127.0.0.0/8), breaking the intentional
http://localhost development exception. The custom DialContext is kept
but uses networking.IsPrivateIP instead of the duplicated block list.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Follow-up to jhrozek's review of the FetchJSON comment: pkg/networking/fetch.go: - Add WithMaxResponseSize(int64) FetchOption so callers can enforce tighter limits; CIMD uses 10 KB vs the 1 MB default - Fix Content-Type validation bug: old strings.Contains check rejected valid application/*+json subtypes (e.g. ld+json). Replace with mime.ParseMediaType + RFC 6839 prefix/suffix check, which is the same logic CIMD already used correctly pkg/oauthproto/cimd/fetch.go: - FetchClientMetadataDocument delegates to networking.FetchJSON with WithMaxResponseSize(10*1024); removes the manual HTTP fetch loop, body-drain defer, and the now-unused isJSONSubtype helper (~30 lines) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- CheckRedirect: remove dead code. `len(via) >= 1` fired on the first redirect (via already contains the original request), making the validateCIMDClientURL call unreachable. Per CIMD §4 the AS MUST NOT automatically follow redirects, so simplify to unconditional ErrUseLastResponse with the spec quote in the comment. - validateCIMDClientURL: use parsed.Scheme instead of strings.HasPrefix for the HTTPS check. URI schemes are case-insensitive per RFC 3986 §3.1; HTTPS://example.com/ is valid but the prefix check rejected it with a misleading error. The loopback check already used parsed.Scheme correctly. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Summary
Phase 2 PR 1 of CIMD embedded AS support — closes part of #4825.
The embedded authorization server (Phase 2) will need to fetch and validate Client ID Metadata Documents when MCP clients present HTTPS URLs as
client_id. This PR adds that foundation as a pure library layer with no server wiring — wiring comes in PR 2 (storage decorator) and PR 3 (config + discovery).FetchClientMetadataDocumentfetches a CIMD document with the security controls required by the spec: HTTPS-only, 5 s timeout, 10 KB body cap, one-hop redirect limit, per-dial SSRF protection, Content-Type validation, and strict self-referential binding (client_idmust exactly equal the serving URL).ValidateClientMetadataDocumentchecks required fields and allowed redirect URI schemes.ClientMetadataDocumentstruct matches draft-ietf-oauth-client-id-metadata-document fields.pkg/networking: RFC6598 CGN (100.64.0.0/10) and RFC5737 documentation ranges added to the private IP block list.WithDisableKeepAlivesonHttpClientBuilderso callers can prevent connection reuse from bypassing per-dial SSRF checks.The SSRF check in
cimd.gois implemented inline rather than viapkg/networkingto preserve theoauthprotoleaf-package invariant (documented indoc.go).Type of change
Test plan
go test ./pkg/oauthproto/... ./pkg/networking/...— all passapplication/*+jsonsubtype,client_idmismatch, missingredirect_uris, SSRF private IP rejectionSpecial notes for reviewers
The duplicate SSRF block list in
cimd.gois intentional — see the comment referencingdoc.go. If the leaf-package invariant is ever relaxed,cimdPrivateBlockscan be replaced with a call tonetworking.IsPrivateIP.Generated with Claude Code