AI tried to fix this
Added private-IP refusal in safeDialContext.
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Did not cover 6to4, NAT64, site-local, and related translated address forms.
How AI contributed: Incomplete remediationPublished May 8, 2026 in
modelcontextprotocol/registryAll 3 findings in this repository
Summary The Registry's HTTP-based namespace verification (POST /v0/auth/http, POST /v0.1/auth/http) uses safeDialContext (internal/api/handlers/v0/auth/http.go:67-110) to refuse dialling private/internal addresses when fetching the well-known public-key file from a publisher-supplied domain. The blocklist (isBlockedIP, lines 125-133) relies entirely on Go stdlib's IsLoopback / IsPrivate / IsLinkLocalUnicast / IsMulticast / IsUnspecified plus a manual CGNAT range. None of these cover IPv6 6to4 (2002::/16), NAT64 (64:ff9b::/96 and 64:ff9b:1::/48 per RFC 8215), or deprecated site-local (fec0::/10) — all of which encode arbitrary IPv4 in the address bits and tunnel to RFC1918 / cloud-metadata services on dual-stack / NAT64-enabled hosts. This is the same CWE-918 SSRF class fixed in GHSA-56c3-vfp2-5qqj on czlonkowski/n8n-mcp (CVSS 8.5 HIGH). The remediation pattern is identical: extend the blocklist with the IPv6 prefix families that embed IPv4. The endpoint is unauthenticated — it is the login flow itself — so attack complexity is low aside from the host-level routing dependency. Affected: latest main HEAD 23f4fda and current production v1.7.6 deployment at https://registry.modelcontextprotocol.io/v0/auth/http. Details Vulnerable code internal/api/handlers/v0/auth/http.go:125-133: Per Go source (src/net/ip.go), the relevant stdlib helpers cover: Helper: IPv6 coverage IsLoopback: ::1, IPv4-mapped of 127/8 (via To4() fast-path) IsPrivate: ULA fc00::/7 only — ip[0]&0xfe == 0xfc IsLinkLocalUnicast: fe80::/10 only — ip[1]&0xc0 == 0x80 (NOT fec0::/10 which is 0xc0) IsMulticast: ff00::/8 IsUnspecified: :: The Registry's blocklist therefore does not cover: Prefix: Defined in: Why dangerous 2002::/16: RFC 3056 (6to4): Bits 16-47 embed an arbitrary IPv4 address. 2002:a9fe:a9fe:: is the 6to4 encoding of 169.254.169.254 (AWS / Azure metadata). 2002:0a00:0001:: encodes 10.0.0.1. On hosts with 6to4 routing or any explicit 2002::/16 route, the dial reaches the embedded IPv4. 64:ff9b::/96: RFC 6052 (NAT64 well-known prefix): Low 32 bits embed an IPv4 address. 64:ff9b::a9fe:a9fe translates to 169.254.169.254 on any NAT64-enabled network — which is the default in IPv6-only GKE node pools, AWS IPv6-only EC2, Azure IPv6 VMs with NAT64, and DNS64/NAT64 corporate networks. 64:ff9b:1::/48: RFC 8215 (local-use NAT64): Same tunnelling concern, intended for operator-defined NAT64. fec0::/10: RFC 3879 (deprecated site-local): Some BSD / older Linux stacks still honour these for routing into site-local internal networks. safeDialContext resolves DNS once and dials by IP (good — pins against rebinding TOCTOU), but the IP-allowlist gate is the security boundary, and that gate is incomplete. Exposure surface POST /v0/auth/http (and POST /v0.1/auth/http) is registered in internal/api/handlers/v0/auth/http.go:197-218 and routed unauthenticated in internal/api/router/v0.go:24,39: The handler builds https://<attacker-domain>/.well-known/mcp-registry-auth (line 143) and dials via the safeDialContext-equipped client. The domain parameter is taken verbatim from the unauthenticated POST body. Critical order-of-operations confirmation in CoreAuthHandler.ExchangeToken (internal/api/handlers/v0/auth/common.go:246-265): 1. ValidateDomainAndTimestamp(domain, timestamp) — domain format check (no IP literal, must contain dot) 2. DecodeAndValidateSignature(signedTimestamp) — hex decode 3. keyFetcher(ctx, domain) ← SSRF dial happens here 4. VerifySignatureWithKeys(...) ← only AFTER fetch So the SSRF dial fires before any signature verification. Attacker needs only a valid RFC3339 timestamp (±15s window) and any hex string for signedTimestamp. PoC Tested against main HEAD 23f4fda (make dev-compose boots Registry on localhost:8080). Step 1 — Set up attacker DNS Configure attacker.example with the AAAA records: (Equivalent free options: a domain on Cloudflare with manual AAAA, or a requestbin-style service with custom DNS.) Step 2 — Trigger the dial (no credentials required) Timestamp need only be within ±15s of server clock. signedTimestamp is any hex string — it is decoded but only verified AFTER FetchKey has already dialled. Step 3 — Observe On a NAT64-enabled host (default in IPv6-only GKE / AWS IPv6 nodes / Cloudflare WARP), the server-side dial reaches 169.254.169.254:443. Tcpdump on the registry host confirms the outbound TLS handshake to the embedded IPv4. Where 169.254.169.254 listens on a TLS port (most cloud metadata services do not, but kube-apiserver, internal admin panels, and bespoke IPv4 services do), the connection completes and the response (limited to 4 KiB by MaxKeyResponseSize) is consumed as a key candidate. For hosts without 6to4 / NAT64 routing, the dial fails with no route to host rather than refusing to connect to private or loopback address — proving the gate did not block. The differential error message provides a blind-SSRF oracle for probing internal services for existence / TLS port reachability. Expected behaviour after fix isBlockedIP should return true for any IPv6 address in the prefix families listed above, mirroring the n8n-mcp isPrivateOrMappedIpv6 helper (GHSA-56c3-vfp2-5qqj patch). Reference implementation: Then extend the call site: A regression test fixture should set up a stub resolver returning each of the four prefix families and assert that safeDialContext returns the "private/loopback" error before any dial. Impact CWE: CWE-918 Server-Side Request Forgery (consistent with parent precedent GHSA-56c3-vfp2-5qqj). CVSS:3.1: matching the n8n-mcp precedent (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N ~= 8.5 HIGH). AC = High because exploitation depends on the registry host having NAT64 or 6to4 routing — the default on IPv6-only and dual-stack cloud network plans (GKE IPv6, AWS IPv6-only EC2, Azure IPv6 VMs with NAT64) but not on plain-IPv4 deployments. Privileges = None (the endpoint is the login flow itself). For the official https://registry.modelcontextprotocol.io deployment specifically, this lets an unauthenticated attacker reach any IPv4 address that is routable from the registry's outbound interface — including AWS / GCP / Azure metadata services if hosted on a cloud VM with metadata enabled, internal Kubernetes API servers, internal admin panels, etc. The 4 KiB response cap (MaxKeyResponseSize) limits exfiltrated content per request but does not prevent fingerprinting / oracle attacks (status-code differential, response-length differential). Self-hosters running the registry on dual-stack / IPv6-only infrastructure are equally exposed. Why this slipped past PR #1227 The April 29 hardening batch (commit 1201cbd, "security: fix open redirect and add small hardening") explicitly added safeDialContext to block "loopback, RFC1918, link-local, multicast, CGNAT, or IP-literal/single-label" addresses. The author correctly identified the IPv4 attack surface and the link-local cloud-metadata vector, but composed the blocklist from Go's per-class stdlib helpers — which collectively miss the IPv6 prefix families that *embed* IPv4. The same gap was caught and fixed in n8n-mcp (GHSA-56c3-vfp2-5qqj). No commits in git log --since=2026-03-01 internal/api/handlers/v0/auth/http.go reference 6to4 / NAT64 / site-local. Credit Reported by Matteo Panzeri (GitHub: matte1782).
The released safeDialContext classifier relied on Go address-class helpers plus CGNAT and omitted 6to4, NAT64 well-known/local-use, and site-local IPv6. The final fix parses and checks those four CIDRs before any dial.
Case facts
The highlighted steps are this advisory: the incomplete AI patch and the later commit that closed it.
This advisoryCVE-2026-44430 (GHSA-R48C-V28R-PF6V)
AI tried to fix this
The AI-assisted change was a real security patch, but it left the same advisory reachable.
Missed: Did not cover 6to4, NAT64, site-local, and related translated address forms.
Fixed again
This is the patch that actually stops the same attack path.
@@ -4,6 +4,7 @@ import ( "context" "fmt" "io"+ "net" "net/http" "strings" "time"@@ -34,6 +35,9 @@ type DefaultHTTPKeyFetcher struct { // NewDefaultHTTPKeyFetcher creates a new HTTP key fetcher with timeout func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher {+ transport := http.DefaultTransport.(*http.Transport).Clone()+ transport.DialContext = safeDialContext+ return &DefaultHTTPKeyFetcher{ client: &http.Client{ Timeout: 10 * time.Second,@@ -42,10 +46,92 @@ func NewDefaultHTTPKeyFetcher() *DefaultHTTPKeyFetcher { CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },+ Transport: transport, }, } } +// safeDialContext resolves the target hostname and refuses to dial loopback,+// private (RFC1918, ULA), link-local, or unspecified addresses. Combined with+// IsValidDomain rejecting IP literals, this neutralises SSRF abuse of the+// well-known fetcher: an attacker cannot reach internal HTTPS services+// (Kubernetes API server, internal admin panels, internal DNS-resolved hosts)+// even if they control DNS for an attacker domain.+//+// The hostname is resolved once here; we then dial the resolved IP directly,+// which pins the connection against DNS rebinding (a TOCTOU where the resolver+// returns a public IP to a pre-flight check and an internal IP to the actual+// dial). TLS SNI and the Host header continue to use the original hostname+// since they are set by http.Transport from the request URL, not the dial+// address.+func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {+ host, port, err := net.SplitHostPort(addr)+ if err != nil {+ return nil, err+ }++ var resolver net.Resolver+ ips, err := resolver.LookupIPAddr(ctx, host)+ if err != nil {+ return nil, err+ }++ // Try each non-blocked address in order, falling through on dial failure.+ // Without this, a stale public AAAA record that no longer routes (or any+ // individually-unreachable IP) breaks auth where the default transport+ // would have recovered by trying the next answer.+ //+ // Each attempt is bounded by perIPDialTimeout so that a single hanging+ // address can't consume the whole http.Client budget. This is a+ // simpler substitute for Happy Eyeballs (parallel A/AAAA racing) — we+ // fail fast and try the next answer instead of racing them.+ const perIPDialTimeout = 3 * time.Second++ var lastErr error+ allBlocked := true+ for _, ip := range ips {+ if isBlockedIP(ip.IP) {+ continue+ }+ allBlocked = false+ dialCtx, cancel := context.WithTimeout(ctx, perIPDialTimeout)+ var d net.Dialer+ conn, dialErr := d.DialContext(dialCtx, network, net.JoinHostPort(ip.IP.String(), port))+ cancel()+ if dialErr == nil {+ return conn, nil+ }+ lastErr = dialErr+ }+ if allBlocked {+ return nil, fmt.Errorf("dial %s: refusing to connect to private or loopback address", host)+ }+ return nil, fmt.Errorf("dial %s: all resolved public addresses failed: %w", host, lastErr)+}++// cgnatRange covers RFC 6598 Carrier-Grade NAT (100.64.0.0/10), which the+// stdlib does not classify via any Is* helper but is reachable on some+// cloud / mobile networks where it shadows internal infrastructure.+var cgnatRange = func() *net.IPNet {+ _, n, _ := net.ParseCIDR("100.64.0.0/10")+ return n+}()++// isBlockedIP reports whether an IP must not be dialled by the namespace+// verification fetcher. Covers loopback (127/8, ::1), RFC1918 + ULA via+// IsPrivate, link-local (169.254/16, fe80::/10 — includes cloud metadata+// 169.254.169.254), unspecified (0.0.0.0, ::), all multicast (admin-scoped+// 239/8 and ff00::/8 in addition to link-local-multicast), and CGNAT.+func isBlockedIP(ip net.IP) bool {+ if ip == nil {+ return true+ }+ return ip.IsLoopback() || ip.IsPrivate() ||+ ip.IsLinkLocalUnicast() || ip.IsMulticast() ||+ ip.IsUnspecified() ||+ cgnatRange.Contains(ip)+}+ // NewDefaultHTTPKeyFetcherWithClient creates a new HTTP key fetcher with a custom HTTP client. // This is primarily useful in tests to inject transports or TLS settings. func NewDefaultHTTPKeyFetcherWithClient(client *http.Client) *DefaultHTTPKeyFetcher {@@ -109,27 +109,65 @@ func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error return nil, fmt.Errorf("dial %s: all resolved public addresses failed: %w", host, lastErr) } +// mustCIDR parses a CIDR literal at init; panics on malformed input so a+// typo surfaces at startup rather than letting a nil *IPNet through to the+// blocklist check (which would silently fail-open).+func mustCIDR(s string) *net.IPNet {+ _, n, err := net.ParseCIDR(s)+ if err != nil {+ panic(fmt.Sprintf("auth: invalid CIDR %q: %v", s, err))+ }+ return n+}+ // cgnatRange covers RFC 6598 Carrier-Grade NAT (100.64.0.0/10), which the // stdlib does not classify via any Is* helper but is reachable on some // cloud / mobile networks where it shadows internal infrastructure.-var cgnatRange = func() *net.IPNet {- _, n, _ := net.ParseCIDR("100.64.0.0/10")- return n-}()+var cgnatRange = mustCIDR("100.64.0.0/10")++// blockedIPv6Prefixes are IPv6 ranges that either embed an arbitrary IPv4+// address (and therefore tunnel into RFC1918 / cloud-metadata space on+// hosts with the corresponding routing) or are routed into site-local+// internal networks. None of these are caught by Go's per-class Is*+// helpers.+//+// 2002::/16 RFC 3056 6to4 — bits 16-47 are an IPv4 address+// 64:ff9b::/96 RFC 6052 NAT64 well-known — low 32 bits are IPv4+// 64:ff9b:1::/48 RFC 8215 NAT64 local-use — same IPv4-embedding shape+// fec0::/10 RFC 3879 site-local (deprecated, still routed by some+// stacks)+var blockedIPv6Prefixes = []*net.IPNet{+ mustCIDR("2002::/16"),+ mustCIDR("64:ff9b::/96"),+ mustCIDR("64:ff9b:1::/48"),+ mustCIDR("fec0::/10"),+} // isBlockedIP reports whether an IP must not be dialled by the namespace // verification fetcher. Covers loopback (127/8, ::1), RFC1918 + ULA via // IsPrivate, link-local (169.254/16, fe80::/10 — includes cloud metadata // 169.254.169.254), unspecified (0.0.0.0, ::), all multicast (admin-scoped-// 239/8 and ff00::/8 in addition to link-local-multicast), and CGNAT.+// 239/8 and ff00::/8 in addition to link-local-multicast), CGNAT, and+// IPv6 prefix families that tunnel to or embed arbitrary IPv4 addresses+// (see blockedIPv6Prefixes). IPv4-mapped IPv6 (::ffff:0:0/96) is handled+// implicitly: the stdlib Is* helpers honour the To4() fast-path, so e.g.+// ::ffff:10.0.0.1 is correctly classified as IsPrivate. func isBlockedIP(ip net.IP) bool { if ip == nil { return true }- return ip.IsLoopback() || ip.IsPrivate() ||+ if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() ||- cgnatRange.Contains(ip)+ cgnatRange.Contains(ip) {+ return true+ }+ for _, p := range blockedIPv6Prefixes {+ if p.Contains(ip) {+ return true+ }+ }+ return false } // NewDefaultHTTPKeyFetcherWithClient creates a new HTTP key fetcher with a custom HTTP client.AI-assisted change f7e60098401caee031354790e06f88edca6a4920c9a58f6a146cd5dea1a1ed0d · Fix 133f36b0a87d02f9d755eb1326cf790835e0a4746eb73110d2324167ff371a51