fetch with SSRF protection. DNS pinning, private IP blocking, size caps, safe redirects. Zero dependencies.
npm i fetch-fence
Three separate axios advisories in two years, all the same class of bug:
| advisory | bug |
|---|---|
| CVE-2024-39338 | SSRF |
| GHSA-jr5f-v2jv-69x6 | SSRF + credential leakage via absolute URL |
| CVE-2025-58754 | DoS, no response size check |
fetch-fence closes all three by construction, and the one nobody else closes: DNS rebinding.
Most SSRF guards do this:
const ip = await dns.lookup(new URL(url).hostname)
if (isPrivate(ip)) throw new Error('blocked')
await fetch(url) // resolves AGAINThe attacker's DNS returns a public IP on the first lookup and 127.0.0.1 on the second. The check passes, the socket goes to your loopback.
fetch-fence resolves once, then dials that exact address, keeping the Host header and TLS SNI intact. There is no second lookup.
import { fetchFence } from 'fetch-fence'
const res = await fetchFence('https://api.example.com/data')Deny by default: HTTPS only, ports 80/443, public IPs only, 3 redirects, 8 MiB, 10s.
Bind a policy once:
import { createFence } from 'fetch-fence'
const fetch = createFence({
allowHosts: ['api.stripe.com', '.githubusercontent.com'],
maxBytes: 1024 * 1024,
timeoutMs: 5000
})
await fetch('https://api.stripe.com/v1/charges')Handle a block:
import { fetchFence, blocked } from 'fetch-fence'
try {
await fetchFence(userSuppliedUrl)
} catch (e) {
if (blocked(e)) console.log(e.code, e.message, e.detail)
else throw e
}| option | default | |
|---|---|---|
allowHosts |
[] |
exact host, or .example.com for subdomains. empty means any public host |
denyHosts |
[] |
checked before allowHosts |
allowSchemes |
['https:'] |
|
allowPorts |
[80, 443] |
or 'any' |
allowScopes |
['public'] |
see below |
maxRedirects |
3 |
every hop is re-checked and re-pinned |
maxBytes |
8388608 |
counted while streaming, not from content-length |
timeoutMs |
10000 |
whole request including redirects |
allowCredentialForwarding |
false |
keep auth headers across an origin change |
IPv4: 0.0.0.0/8 10/8 100.64/10 127/8 169.254/16 (incl. 169.254.169.254) 172.16/12 192.168/16 192.0.0/24 198.18/15 224/4 240/4 and the TEST-NET blocks.
IPv6: :: ::1 fc00::/7 fe80::/10 ff00::/8 2001:db8::/32 64:ff9b::/96.
Bypasses that are handled:
'http://2130706433/' // decimal -> 127.0.0.1
'http://0x7f.0.0.1/' // hex -> 127.0.0.1
'http://127.1/' // short -> 127.0.0.1
'http://[::ffff:127.0.0.1]/' // v4-mapped -> loopback
'http://[::ffff:7f00:1]/' // v4-mapped hex
'0177.0.0.1' // octal -> rejected outrightRedirect chains are followed manually. Each hop goes through the same policy and gets its own pin, so a 302 to 169.254.169.254 is blocked like a direct request. Auth, cookie and proxy-auth headers are dropped when the origin changes.
import { isPublicIp, parseIp, checkUrl, resolvePolicy } from 'fetch-fence'
isPublicIp('169.254.169.254') // false
parseIp('::ffff:10.0.0.1').scope // 'private'
checkUrl('https://x.com/', resolvePolicy({ allowHosts: ['y.com'] })) // throwsBLOCKED_IP BLOCKED_HOST BLOCKED_SCHEME BLOCKED_PORT NO_ALLOWED_ADDRESS TOO_MANY_REDIRECTS BODY_TOO_LARGE TIMEOUT DNS_FAILED
Node 18+. No dependencies. TypeScript types included.
A fresh socket per request — pooled keep-alive sockets outlive the policy that cleared them.
MIT