fetch-fence
v0.1.0
Published
fetch with ssrf protection. dns pinning, private ip blocking, size caps, safe redirects. zero deps
Maintainers
Readme
fetch-fence
fetch with SSRF protection. DNS pinning, private IP blocking, size caps, safe redirects. Zero dependencies.
npm i fetch-fencewhy
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.
the DNS rebinding problem
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.
use
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
}policy
| 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 |
what gets blocked
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.
checks on their own
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'] })) // throwserror codes
BLOCKED_IP BLOCKED_HOST BLOCKED_SCHEME BLOCKED_PORT NO_ALLOWED_ADDRESS TOO_MANY_REDIRECTS BODY_TOO_LARGE TIMEOUT DNS_FAILED
notes
Node 18+. No dependencies. TypeScript types included.
A fresh socket per request — pooled keep-alive sockets outlive the policy that cleared them.
licence
MIT
