i've reported 5 SSRF bypass CVEs in major open-source projects — librechat (35k stars), twenty crm (44k stars), stirling-pdf (77k stars), flowise (47k stars), and new-api (27k stars). the same class of bug, over and over. the root cause isn't lazy developers. it's that every programming language handles ipv4-mapped ipv6 addresses differently, and almost nobody knows how their language actually behaves.
this post documents the exact runtime behavior of 7 languages when they encounter ::ffff:127.0.0.1. every output below is from real code execution, not documentation. the results are worse than you think.
what is an ipv4-mapped ipv6 address?
ipv6 has a compatibility feature: you can represent any ipv4 address in ipv6 notation by prepending ::ffff:. so 127.0.0.1 becomes ::ffff:127.0.0.1. same machine, same destination, different notation.
the problem starts when you realize that 169.254.169.254 (the cloud metadata endpoint) can also be written as ::ffff:169.254.169.254. and that 169 in decimal is a9 in hex. and 254 is fe. so 169.254 becomes a9fe. the full address can be written as ::ffff:a9fe:a9fe.
all of these point to the same server:
| form | status against typical ssrf filter |
169.254.169.254 | blocked |
::ffff:169.254.169.254 | depends |
::ffff:a9fe:a9fe | bypass |
0:0:0:0:0:ffff:a9fe:a9fe | bypass |
[::ffff:a9fe:a9fe] | bypass |
the question is: does your language's standard library help you catch these, or does it make things worse?
node.js v25 — the silent hex converter
node.js has the most dangerous behavior of all tested languages.
when you pass an ipv4-mapped address through new URL(), node's WHATWG URL parser converts the dotted-decimal ipv4 portion to hexadecimal:
new URL('http://[::ffff:169.254.169.254]/').hostname → [::ffff:a9fe:a9fe]
new URL('http://[::ffff:127.0.0.1]/').hostname → [::ffff:7f00:1]
new URL('http://[::ffff:10.0.0.1]/').hostname → [::ffff:a00:1]
this is the conversion that broke librechat's ssrf protection. their isPrivateIP function used a regex that matched digits with dots. after new URL(), the hostname contains letters and colons. the regex never matches. bypass.
to make matters worse, node has no built-in isPrivate() or isLoopback() for IP addresses. net.isIP() tells you if it's v4 or v6 but not if it's private. net.BlockList exists but requires you to declare ipv4 and ipv6 rules separately — ipv4 rules don't match ipv4-mapped ipv6. you're on your own.
bottom line: node.js actively transforms your input into a form that bypasses common protections, then gives you no tools to handle the transformed form.
python 3.14 — the silent False
python doesn't normalize to hex — ipaddress.ip_address('::ffff:127.0.0.1') preserves the dotted form. and since python 3.12.4, .is_loopback and .is_private correctly return True for ipv4-mapped addresses. if you're on a recent python, the predicates work.
the trap is elsewhere. range-based containment — the approach most ssrf filters actually use — fails silently:
addr = ipaddress.ip_address('::ffff:127.0.0.1')
net = ipaddress.ip_network('127.0.0.0/8')
print(addr in net) # False — no error, just wrong
an IPv6Address is never "in" an IPv4Network. python doesn't raise a TypeError. it just returns False. your filter thinks the address is safe.
the fix: call .ipv4_mapped first to extract the IPv4Address, then check containment. but most developers don't know .ipv4_mapped exists.
bottom line: python's tools are correct if you know the right incantation. most people don't.
go 1.22 — the api migration trap
go has two IP address APIs, and they behave differently.
the old net.IP API silently collapses ipv4-mapped to ipv4:
net.ParseIP("::ffff:127.0.0.1").String() → "127.0.0.1"
net.ParseIP("::ffff:127.0.0.1").IsLoopback() → true
this is actually safe for ssrf filters. the mapping disappears, and the loopback check works.
the modern netip.Addr API does the opposite:
netip.MustParseAddr("::ffff:127.0.0.1").IsLoopback() → false
netip.MustParseAddr("::ffff:127.0.0.1").IsPrivate() → false
on netip.Addr, you must call .Unmap() first. without it, all predicates return false for mapped addresses. the "new correct" API is the dangerous one.
bottom line: go developers migrating from net.IP to netip.Addr are introducing ssrf bypasses. the old api was accidentally safer.
java 17 — the implicit unmapper
java takes the opposite approach from everyone else. InetAddress.getByName("::ffff:127.0.0.1") returns an Inet4Address, not an Inet6Address. the mapping vanishes completely:
InetAddress.getByName("::ffff:127.0.0.1").getClass() → Inet4Address
InetAddress.getByName("::ffff:127.0.0.1").getHostAddress() → "127.0.0.1"
InetAddress.getByName("::ffff:127.0.0.1").isLoopbackAddress() → true
if you resolve through InetAddress.getByName() and then check predicates, you're safe. java does the unmapping for you.
bottom line: java is the safest by default, but only if you go through InetAddress. string-based filters still break.
php 8.3 — the FILTER_FLAG footgun
php's filter_var with FILTER_FLAG_NO_PRIV_RANGE is the recommended way to validate IPs in many tutorials. it doesn't work for ipv4-mapped addresses:
filter_var('::ffff:127.0.0.1', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) → valid (accepts!)
filter_var('::ffff:10.0.0.1', FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) → valid (accepts!)
the flag only checks ipv4 rfc 1918 ranges. ipv4-mapped ipv6 addresses representing private IPs pass through. every php ssrf guide that recommends FILTER_FLAG_NO_PRIV_RANGE alone is producing vulnerable code.
bottom line: the most commonly recommended php ssrf defense doesn't catch ipv4-mapped addresses.
ruby 2.6 — the one that accidentally got range matching right
ruby's IPAddr has loopback? and private? predicates, and they both return false for mapped addresses. but range inclusion actually works across families:
IPAddr.new('127.0.0.0/8').include?(IPAddr.new('::ffff:127.0.0.1')) # → true
this is the only language tested where a v4 range correctly matches an ipv4-mapped v6 address.
bottom line: ruby accidentally got the hard part right (range matching) and the easy part wrong (predicates).
rust 1.95 — explicit but unforgiving
rust parses ipv4-mapped addresses as IpAddr::V6. Ipv6Addr::is_loopback() only checks for ::1, so ::ffff:127.0.0.1 is not considered loopback. there is no is_private() method on Ipv6Addr at all.
let v6: Ipv6Addr = "::ffff:127.0.0.1".parse().unwrap();
v6.is_loopback() // false
v6.to_ipv4_mapped().unwrap().is_loopback() // true
bottom line: rust makes you do the work explicitly. code that skips the unmapping step is silently broken.
the cross-language summary
| language |
is_loopback? |
is_private? |
range match? |
built-in unmap? |
normalizes to hex? |
| node.js |
no api |
no api |
n/a |
no |
yes |
| python |
yes |
yes |
no (silent) |
.ipv4_mapped |
no |
| go (netip) |
no |
no |
n/a |
.Unmap() |
no |
| go (net.IP) |
yes (auto) |
partial |
n/a |
.To4() |
no |
| java |
yes (auto) |
yes |
n/a |
automatic |
no |
| php |
no |
accepts! |
n/a |
no |
no |
| ruby |
no |
no |
yes |
.native |
no |
| rust |
no |
no api |
n/a |
.to_ipv4_mapped() |
no |
the pattern is clear: in 5 out of 7 languages, the standard predicates fail on ipv4-mapped addresses.
where ssrf filters break
based on these results and the CVEs i've reported, ssrf filters break in four specific ways:
string-prefix filters. any filter that checks hostname.startsWith("127.") or hostname.startsWith("10.") misses every ipv4-mapped form. in node.js it's even worse because the hostname becomes [::ffff:7f00:1] — hex with brackets.
range-based containment. python's ip_address in ip_network returns silent False across families. no error, no warning. the filter thinks the address is safe.
predicate-only checks. calling .is_private() or .is_loopback() directly on the mapped address fails in go (netip), ruby, rust, and php. only java and recent python get this right.
missing unmapping step. every language except java requires an explicit unmapping call. if the developer doesn't know this step exists, the filter has a gap.
the minimum viable ssrf guard
based on testing all 7 languages, here's what a correct ssrf filter must do:
1. parse the input as an IP address
2. if it's ipv6, check if it's ipv4-mapped. if so, extract the embedded ipv4 and re-run all checks
3. check against all private ranges: loopback (127/8), rfc 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), cgnat (100.64/10), ipv6 loopback (::1), ipv6 link-local (fe80::/10), ipv6 unique-local (fc00::/7)
4. do all of this after DNS resolution, not on the input string
or better yet: don't implement it yourself. every custom implementation i've audited had at least one gap.
real-world impact
these aren't theoretical concerns. here are 5 CVEs i reported, all caused by these exact behaviors:
— librechat (CVE-2026-31943, CVSS 8.5) — node.js URL parser normalized to hex, regex didn't match. 35k stars.
— twenty crm (GHSA-vrcj-hv2q-c58m) — same pattern, independent codebase. 44k stars.
— stirling-pdf (GHSA-hg2c-wm3r-f7xx) — missing rfc 6598 range. 77k stars, 20M downloads.
— flowise (GHSA-r745-8hwv-h473) — unauth oauth2 refresh enabled non-blind ssrf. 47k stars.
— new-api (GHSA-6qcr-qxgr-m7fv) — unresolved hostname bypassed ip filter. 27k stars.
230k+ combined stars. five projects, five bypasses. the pattern is systematic.
test it yourself
open a node.js terminal and run:
$ node -e "console.log(new URL('http://[::ffff:169.254.169.254]/').hostname)"
if you see [::ffff:a9fe:a9fe], your ssrf filter that checks for 169.254 will never see it.
now go look at your project's isPrivateIP function. does it handle this?