Strip away the product language and a screenshot API is a machine that takes URLs from strangers and opens them inside your network. Server-side request forgery isn't a risk this product mitigates — SSRF is the core product loop itself. So if headless browsers touch user input anywhere in your stack, consider this the checklist we wanted and couldn't find. Everything here runs in production right now.

The threat model is unusually honest

There's no trickery required: nobody needs to smuggle an attacker-controlled URL into your service, because fetching attacker-controlled URLs is what the service does. What the attacker wants:

  • Internal network probing — point the renderer at http://10.0.4.17:8080/admin and it becomes a port scanner sitting behind your firewall, one that politely screenshots whatever it finds.
  • Cloud metadata endpoints — on most cloud platforms, http://169.254.169.254/... will hand over IAM credentials. That one address sits behind several of the most expensive breaches of the past decade.
  • Loopback services — the database admin panel, the queue dashboard, every service that ever trusted 127.0.0.1 to mean "just us".

The obvious defense — a regex or an IP check before you fetch — fails in two separate ways. Both are worth understanding in detail.

Failure one: an IP address has more spellings than you think

127.0.0.1 can be written as http://2130706433/ (decimal), as http://0x7f000001/ (hex), as http://0177.0.0.1/ (octal), or as http://[::ffff:127.0.0.1]/ (IPv4-mapped IPv6). A string match against "127." catches exactly none of these.

Stop pattern-matching. Let a real parser normalize the input first — the WHATWG URL parser built into Node and every browser already folds the exotic IPv4 spellings down to dotted-decimal before your code ever sees them:

const url = new URL('http://0x7f000001/')
url.hostname // → '127.0.0.1' — already canonical, safe to check against ranges

That's our first layer, in full: parse; allow nothing but http: and https:; reject any URL carrying credentials; reject reserved hostnames (localhost, *.internal, *.local, *.home.arpa) before DNS is ever consulted; then check IP literals against the blocklist in canonical form.

The blocklist itself — and the IPv4-mapped trap

We block address space, not individual addresses: the RFC 1918 private ranges, loopback, link-local (which is what covers the metadata endpoint), CGNAT space, TEST-NET and benchmarking ranges, multicast, the reserved 240/4 — and the IPv6 side of each: ULA, link-local, NAT64, the documentation prefix.

Node's net.BlockList handles the range math, and it also hides a trap that deserves a name. BlockList considers an IPv4 address and its IPv4-mapped IPv6 form (::ffff:a.b.c.d) to be the same thing. Add ::ffff:0:0/96 to your main blocklist to kill mapped literals and you have blocked the entire IPv4 internet: every dotted-decimal address now matches the mapped subnet, and every render fails closed.

// Wrong: this blocks ALL of IPv4, not just mapped literals
blockList.addSubnet('::ffff:0:0', 96, 'ipv6')

// Right: keep a separate list, consult it only for family === 6
const mappedV4 = new net.BlockList()
mappedV4.addSubnet('::ffff:0:0', 96, 'ipv6')

function isBlockedIp(ip: string): boolean {
  const family = net.isIP(ip)
  if (family === 4) return blockList.check(ip, 'ipv4')
  if (family === 6) return mappedV4.check(ip, 'ipv6') || blockList.check(ip, 'ipv6')
  return true // not a valid IP literal → reject
}

We don't bother unwrapping mapped forms — we reject them outright. A legitimate public site has a real A record; nothing legitimate ever needs to be addressed as ::ffff:....

Layer three: resolve everything, reject on any hit, pin one answer

For hostnames we resolve every A and AAAA record, and one bad answer kills the URL — if any single record lands in a blocked range, the whole thing is rejected. A domain resolving to one public and one private IP isn't half-safe; split-horizon setups and multi-record responses are a classic smuggling vector.

const records = await lookup(host, { all: true })
if (records.some(r => isBlockedIp(r.address)))
  return reject('resolves to a blocked range')

// Pin one concrete answer (IPv4 preferred) — this exact IP is
// the only place a connection is allowed to go.
const pinned = records.find(r => r.family === 4) ?? records[0]

Pin is the word where most SSRF write-ups stop — and where the actual problem begins.

The boss fight: DNS rebinding

Say you validate a hostname and every record comes back public. Good. Then you hand the URL string to the browser — which resolves the name a second time. An attacker running their own authoritative DNS serves a public IP on the first lookup (your check passes) and 127.0.0.1 on the second. TTL zero, two queries, and your validation ran on a different connection than your fetch. It's a textbook time-of-check/time-of-use gap, and no amount of pre-validation closes it, because the check and the connection have to be the same event.

Our answer is blunt: Chromium never gets to do its own DNS. The renderer starts an in-process forward proxy on loopback, and every single request the browser makes — main document, subresources, each redirect hop — is forced through it:

// Playwright context: all traffic through the guard proxy.
// '<-loopback>' cancels Chromium's default "localhost bypasses
// the proxy" rule — otherwise http://127.0.0.1/ sails straight through.
proxy: { server: `http://127.0.0.1:${proxyPort}`, bypass: '<-loopback>' }

Inside the proxy, every connection re-runs the entire pipeline — validate, resolve, blocklist-check — then dials the pinned IP directly. Same event, no second resolution anywhere:

// HTTPS: CONNECT tunnel. Validate, resolve, then dial the
// pinned IP — never the hostname.
const resolved = await resolveTarget(host)
if (!resolved.ok) return deny(resolved.reason)

const upstream = net.connect(port, resolved.ip, () => {
  clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
  upstream.pipe(clientSocket)
  clientSocket.pipe(upstream)
})

Details that matter:

  • HTTPS stays encrypted. A CONNECT tunnel exposes the hostname and the connection event — all we need to guard at the IP layer. No TLS interception, no re-signed certificates, no crypto liability.
  • Redirects re-enter the guard. Every hop is a fresh browser request, so a public URL that 302s to http://169.254.169.254/ is caught at that hop instead of inheriting trust from the first check. Hop limits stay Chromium's own.
  • Rebinding dies structurally. Check and connect are one operation on one socket; the window between them doesn't exist.

The checklist

If you run headless browsers against user-supplied URLs, in rough order of importance:

  • Normalize with the WHATWG URL parser. Never pattern-match raw strings.
  • Allow http:/https: only; reject credentials in URLs; reject reserved TLDs before DNS.
  • Blocklist ranges across both families: private, loopback, link-local/metadata, CGNAT, TEST-NET, multicast, 240/4 — plus ULA, NAT64, and IPv6 link-local.
  • Keep IPv4-mapped IPv6 in its own list, consulted only for IPv6 literals — otherwise you block the whole IPv4 internet.
  • Resolve every record; a single blocked answer rejects the URL.
  • Route every browser connection — subresources and redirect hops included — through a proxy that re-validates and dials a pinned IP per connection. And kill the localhost proxy bypass.
  • Write the failure tests first: decimal, hex and octal literals, ::ffff: forms, 169.254.169.254, and a domain that returns one public and one private record.

One process rule holds it all together: in our codebase, every code path that fetches an external URL goes through this single guard module. There is never a second implementation — the second copy is the one that rots.


Shotium is a screenshot & OG image API that babysits the browser so you don't have to. If deleting your headless-browser pile sounds better than hardening it: the docs are here, and sign-up comes with 100 free renders. Spotted a hole in our reasoning? Security reports get taken seriously here — contact.