Severity by source
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:L/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X
Primary rating from GitHub Advisory.
CVSS VectorGitHub Advisory
Lifecycle Timeline
4DescriptionGitHub Advisory
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:
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:
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):
ValidateDomainAndTimestamp(domain, timestamp)- domain format check (no IP literal, must contain dot)DecodeAndValidateSignature(signedTimestamp)- hex decodekeyFetcher(ctx, domain)← SSRF dial happens hereVerifySignatureWithKeys(...)← 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)
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:
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:
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).
AnalysisAI
Server-side request forgery in MCP Registry's HTTP namespace verification endpoint allows unauthenticated attackers to reach internal IPv4 addresses via specially-crafted IPv6 addresses that encode or tunnel to RFC1918 and cloud-metadata services. The vulnerability exists in the private-address blocklist used by safeDialContext, which fails to block IPv6 6to4 (2002::/16), NAT64 well-known (64:ff9b::/96), NAT64 local-use (64:ff9b:1::/48), and deprecated site-local (fec0::/10) prefixes. On dual-stack and IPv6-only cloud deployments (GKE IPv6, AWS IPv6-only EC2, Azure NAT64), this enables direct connections to metadata services and internal Kubernetes API servers. No public exploit code identified at time of analysis, but proof-of-concept has been demonstrated against the production registry.
More in Kubernetes
View allA critical vulnerability in Kubernetes ingress-nginx controller allows unauthenticated attackers with pod network access
Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio
Kubernetes ingress-nginx contains a configuration injection vulnerability via the mirror-target and mirror-host Ingress
A security issue was discovered in ingress-nginx https://github.com/kubernetes/ingress-nginx where the `auth-url` Ingres
A security issue was discovered in ingress-nginx https://github.com/kubernetes/ingress-nginx where the `auth-tls-match-c
Kubernetes API server in all versions allow an attacker who is able to create a ClusterIP service and set the spec.exter
A security issue was discovered in Kubernetes where a user that can create pods on Windows nodes may be able to escalate
Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. Rated critical severity (CVSS 9.9), this vulne
Unauthenticated remote attackers can trigger complete database overwrites, server-side file reads, and SSRF attacks agai
The Kubernetes integration in GitLab Enterprise Edition 11.x before 11.2.8, 11.3.x before 11.3.9, and 11.4.x before 11.4
Fluentd configuration injection in the kube-logging Logging operator before 6.6.0 allows a namespace-scoped user who can
Kyverno Kubernetes policy engine prior to 1.x has a privilege escalation vulnerability (CVSS 9.9) allowing policy bypass
Same weakness CWE-918 – Server-Side Request Forgery (SSRF)
View allVendor StatusVendor
SUSE
Severity: Moderate| Product | Status |
|---|---|
| SUSE Linux Enterprise Server 16.1 | Affected |
| SUSE Linux Enterprise Server for SAP applications 16.1 | Affected |
| SUSE Linux Enterprise Module for Package Hub 15 SP5 | Affected |
| SUSE Linux Enterprise Module for Package Hub 15 SP6 | Affected |
| openSUSE Leap 15.5 | Affected |
| openSUSE Leap 15.6 | Affected |
Share
External POC / Exploit Code
Leaving vuln.today
EUVD-2026-30488
GHSA-r48c-v28r-pf6v