Verflink
No data stored Secure server-side checks

My SSRF guard blocked every IPv4 address on the internet

Verflink crawls websites people submit and requests every outbound link it finds. That is textbook SSRF exposure: a stranger hands me a URL and my server fetches it. Point it at 169.254.169.254 and, on a badly configured host, you get cloud metadata credentials back.

So the fetcher validates the resolved IP before connecting, and re-validates on every redirect hop. Node ships net.BlockList for exactly this, and the list looked like you would expect: loopback, RFC1918 ranges, link-local, CGNAT, multicast.

Then I added one more line, for IPv4-mapped IPv6 addresses:

bl.addSubnet('::ffff:0:0', 96, 'ipv6');

The reasoning was sound. ::ffff:7f00:1 is another way of writing 127.0.0.1. If an attacker passes the mapped form, the IPv4 rules never fire, because the address parses as IPv6. Blocking the mapped range closes that door.

I deployed it. Every scan started failing instantly with EBLOCKED. Not slowly, not intermittently: every single one, against ordinary public blogs.

What actually happens

net.BlockList does not keep IPv4 and IPv6 rules in separate universes. Internally an IPv4 address is treated as its mapped equivalent, so a rule covering ::ffff:0:0/96 covers every IPv4 address that exists.

You can watch it happen. With that subnet in the list, check('1.1.1.1', 'ipv4') returns true. So does 8.8.8.8. So does every public address you try. Remove the mapped subnet and the same checks return false, while 127.0.0.1 stays blocked as intended. Verified on Node v24.

The rule was not too broad by a little. ::ffff:0:0/96 is the entire IPv4 address space wearing an IPv6 costume. I had written "block all of IPv4" and read it back as "block one edge case".

Why it took a while to see

The failure mode was maximally confusing. The error came from my own guard, so it looked like the guard was working: something was being blocked, therefore the protection was doing its job. Nothing in the message said which rule matched, only that the address was rejected.

The unit tests passed, too. They asserted that localhost, metadata addresses, and redirects into private space were blocked, and every one of those still was. Not a single test asserted the boring half of the contract: that a normal public address is allowed through. The suite tested that the door locks, never that it opens.

The fix

Keep the mapped range out of the shared list, and give it a list of its own. Then, when validating an address: if it is IPv6 in dotted mapped form, extract the embedded IPv4 and check it against the IPv4 rules. If it is mapped in hex form, reject it outright, since DNS never returns that shape and a literal in a submitted URL is a deliberate attempt. Otherwise check by family, as normal.

The security property is unchanged. The difference is that the rule now lives where it cannot poison every unrelated check.

What I took from it

A rule that blocks too much fails loudly, and that is the good case. Had this gone the other way, with a rule that silently allowed private addresses, nothing would have broken and I would not have known for months.

Test the allow path, not just the deny path. A blocklist has two halves of the contract, and only one of them is fun to write tests for. My suite covered the interesting half and shipped a guard that blocked the internet. There is now a test asserting that a public IPv4 address is allowed.

Errors should name the rule that fired. "Address blocked" told me the outcome and hid the cause. The five minutes it takes to include which subnet matched would have turned an afternoon of debugging into a glance at a log line.


I write this while building Verflink, which crawls sites and checks their outbound links. The crawler is the part that has to fetch arbitrary URLs safely, which is how I ended up here in the first place.

Scan your store's links free

No account, no setup. Point Verflink at your site and see the broken product pages, dead redirects and link failures before your next customer does.