Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

fetch-fence

fetch with SSRF protection. DNS pinning, private IP blocking, size caps, safe redirects. Zero dependencies.

npm i fetch-fence

why

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 AGAIN

The 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 outright

Redirect 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'] }))  // throws

error 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

About

fetch with ssrf protection. dns pinning, private ip blocking, size caps, safe redirects. zero deps

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages