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). all five are ssrf filter bypasses, and the thread running through the two i'll dig into here — librechat and twenty — is this: 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. there's exactly one native tool that gets it right — net.BlockList: an ipv4 rule you add does match the ipv4-mapped form, even after the hex normalization (blocklist.check('::ffff:a00:1', 'ipv6') returns true for a blocked 10.0.0.1), and it correctly lets through anything you didn't list. the catch is that almost nobody reaches for it — everyone hand-rolls a regex instead, and that's exactly where the bypass lives.
bottom line: node.js actively transforms your input into a form that bypasses hand-rolled protections — and the one built-in that handles the transformed form, net.BlockList, is the one nobody uses.
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: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 — got range matching right, fixed its predicates late
ruby's IPAddr has loopback? and private? predicates. for years they both returned false for mapped addresses — and this wasn't just ancient ruby, it persisted through ruby 3.2. private? was fixed in ruby 3.3 (ipaddr 1.2.6, bug #19479) and loopback? a release later in ruby 3.4 (ipaddr 1.2.7). on anything older, the predicates lie. range inclusion, though, 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 got the hard part right (range matching) but the easy part wrong for years — the predicates returned false for mapped until 3.3/3.4, so version matters.
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 (before 3.3/3.4), rust, and php. only java, recent python, and recent ruby 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 ssrf filter-bypass CVEs i reported — two are exactly the ipv4-mapped case above, the other three are related bypasses in the same family (a missing range, an unauth fetch, an unresolved hostname):
— 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 — different representations and entry points, but the same root problem: the code that validates an address and the code that connects to it disagree about what it is.
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?