GitHub Advisoryadvisory · Go

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

LOW Published 2026-07-15 Modified 2026-07-15 Official page ↗
Aliases
CVE-2026-54450
Ecosystem
Go
Packages
github.com/stacklok/toolhive
Severity
Low
CVSS vector
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N/E:P
Weaknesses
CWE-918
Reviewed
2026-07-15

Affected packages

• Go github.com/stacklok/toolhive: < 0.29.1 → fixed in 0.29.1

Description

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:///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`: ```go 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: ```go 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 • Module: `github.com/stacklok/toolhive` • Affected packages/paths: `pkg/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. • Affected versions: all releases through v0.29.0 (the `IsPrivateIP` table has lacked NAT64 entries since the guard was introduced; the CIMD reach was added in v0.28.1). • Precondition for full exploitability: the ToolHive process runs in an environment with a NAT64/DNS64 gateway (e.g. IPv6-only Kubernetes / IPv6-only cloud). The classification defect itself is present unconditionally. 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`: ```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 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 := range ips { …

Related

Alias · CVE (1)

  • CVE-2026-54450 ToolHive is a utility designed to simplify the deployment and management of Model Context Protocol (MCP) servers. vulnerability

Weakness · CWE (1)

  • CWE-918 Server-Side Request Forgery weakness

References