Security
What actually blocks SSRF (and what quietly does not)
Most SSRF filters check the hostname. The attack does not live in the hostname — it lives in what the hostname resolves to at connect time.
Almost every "we block SSRF" claim I read is a prefix check on a hostname. It looks like this, and it fails:
if (url.hostname === 'localhost' || url.hostname.startsWith('127.')) {
throw new Error('blocked');
}
await fetch(url); // 169.254.169.254, 2130706433, [::ffff:7f00:1] all sail throughThe problem is that a hostname is a claim, not an address. Between the moment you validate and the moment your HTTP client connects, the answer can change — and there are at least six encodings of 127.0.0.1 that a string check has never heard of.
The encodings people forget
- Decimal: 2130706433 is 127.0.0.1. Also 0177.0.0.1, 0x7f000001, and 127.1.
- IPv6 loopback and mapped addresses: [::1], [::ffff:127.0.0.1], [::ffff:7f00:1].
- A public hostname that resolves to a private address: internal.example.com → 10.0.0.7.
- A hostname whose answer changes between lookups — the classic DNS rebinding trick.
- Redirects: your fetch of a public URL gets a 302 to http://169.254.169.254/.
- Alternate metadata names: metadata.google.internal, and Azure/GCP equivalents of the same idea.
Resolve first, decide on addresses
The only reliable input to the decision is the set of addresses the connection would actually use, so resolve the name yourself and validate every answer. Node's dns.lookup with all:true returns the full set:
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const BLOCKED = [
['10.0.0.0', 8], ['172.16.0.0', 12], ['192.168.0.0', 16], ['127.0.0.0', 8],
['169.254.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8], ['192.0.0.0', 24],
];
function toLong(ip) {
return ip.split('.').reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
}
function blockedIp(ip) {
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower === '::1' || lower === '::') return true;
if (lower.startsWith('fe80') || lower.startsWith('fc') || lower.startsWith('fd')) return true;
// ::ffff:10.0.0.1 style
const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (mapped) return blockedIp(mapped[1]);
return false;
}
const value = toLong(ip);
return BLOCKED.some(([base, bits]) => {
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return (value & mask) === (toLong(base) & mask);
});
}
export async function assertPublicHost(hostname) {
if (isIP(hostname)) {
if (blockedIp(hostname)) throw new Error('blocked_target');
return;
}
const answers = await lookup(hostname, { all: true, verbatim: true });
if (!answers.length) throw new Error('dns_failure');
// Fail closed: one private answer in the set is enough to refuse the host.
if (answers.some((a) => blockedIp(a.address))) throw new Error('blocked_target');
}Two details in there matter more than they look. First: fail closed on a mixed answer set. A name that returns one public address and one private one is an attack, not a CDN. Second: refuse on lookup failure rather than falling through to the client's own resolution — an error is a decision, not a reason to guess.
Closing the rebinding gap
Validating addresses and then calling fetch() leaves a time-of-check/time-of-use window: fetch resolves the name again, and the second answer can be 169.254.169.254. There are two honest fixes. Connect to the address you validated while keeping the original Host header and TLS SNI, or check the peer address at socket level after connect and abort if it differs. Libraries that hide the socket — most high-level HTTP clients — give you neither, which is exactly why "validate then fetch" is not a complete answer.
Redirects belong to the same family. fetch follows them by default, so a benign-looking URL becomes an internal one after the first hop. Set redirect to manual and return the 3xx to the caller, or re-validate the Location header exactly as strictly as the original target.
What no library decides for you
An SSRF guard answers one question: is this address one the public internet could reach? It cannot answer whether you should be calling this host at all. That is an allowlist — the hosts your product has a reason to talk to — and it is the single strongest control available, because it does not care about encodings or DNS tricks: a host that is not on the list never gets resolved.
- Guard: refuses private, loopback, link-local, CGNAT and metadata ranges, after DNS, with fail-closed semantics.
- Allowlist: only the partner hosts your feature needs. Cheaper to reason about, impossible to bypass with an encoding.
- Ceilings: a byte limit on the request and the response, so a valid target cannot be used to exhaust memory.
- Evidence: one record per attempt, so you can answer "what did we call at 03:14" without a log archaeology project.
A relay like Backfire exists because those four controls are easy to describe and tedious to keep correct in every code path. If you build it yourself, the resolve-then-decide code above is the part to get right first, and the allowlist is the part to add second.
The guard described here is the product.
Backfire validates every target after DNS, refuses private and link-local addresses, never follows a redirect, and writes one record per attempt. 5,000 free trips, no card and no subscription.
Create an accountQuestions about this
Keep reading
Try it with 5,000 free trips.
No card, no subscription, and the balance never expires.

