How-to › Release and secure packages

How to defend a URL-fetching endpoint against SSRF#

Allowlist the scheme and port, resolve the name yourself, refuse special-purpose addresses, connect to the address you checked, and check again on each redirect.

Audience
API producer
Level
advanced
Topic
Harden clients and servers
Languages
TypeScript and JavaScript
Verified

Your link previewer takes a URL from the request body and fetches it. Somebody submits http://169.254.169.254/latest/meta-data/iam/security-credentials/web-role, and the preview comes back holding the server’s own cloud credentials. A host name check does not help, because the attacker controls what the name resolves to, and a redirect lands wherever its author points it.

What you get

You will end up with a fetch function that limits the scheme and port, checks every resolved address, connects to the address it checked, and checks each redirect again. This is for you if your API fetches URLs that callers supply.

Short answer

Parse the URL and allow only http: and https: on ports 80 and 443. Resolve the host name yourself, with every answer, and refuse any address inside the special-purpose ranges: loopback, link-local, private, and the rest of RFC 6890. Then open the socket to the address you checked, never to the name, and run the same check on every redirect. In Node, net.BlockList holds the ranges and createConnection pins the address.

You will need

Node 22 or later. The sample uses only Node built-ins, and the TLS test mints a throwaway certificate with the openssl CLI. Verified 2026-10-21 against Node 22.22.0 and OpenSSL 3.0.13. The attack is API7 in the OWASP API Security Top 10. The OWASP cheat sheet on preventing it asks for the two things this page builds: validated destination addresses, and a client that connects only to one. The public host and the metadata service are loopback stand-ins, so nothing here reaches a network.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A network egress allowlist with no metadata routeA platform you control end to end, where the metadata service can be switched off or hop-limitedRules by address and port only, so every legitimate destination must be listed and a redirect to an attacker’s public host still goes throughThe endpoint must reach arbitrary public hosts, which is what a link previewer is for
In-process allowlist validationOne service with one fetching endpoint, shipped this weekCode that every fetching path must call, and a bypass the moment one path calls fetch directlySeveral services fetch caller URLs, or the host holds credentials worth stealing
stripe/smokescreen egress proxySeveral services fetch caller URLs and you want one enforcement point with its own logA Go service to deploy, scale, and keep patched, plus a client identity to issue to every callerOne service, or no team to run a proxy

In-process validation is the cheapest to ship and the weakest: it protects only the code paths that call it. Smokescreen moves the decision into a proxy every service must use, which costs a Go deployment and a client certificate per caller. A network policy with no route to the metadata address is the one layer code cannot talk around, and it costs a per-destination rule set that a link previewer cannot satisfy.

Check the scheme and port before you resolve anything

The cheap checks come first, so a bad scheme or port never costs a DNS query. new URL does the parsing, and it does more than split the string. The WHATWG URL parser turns http://2130706433/ and http://0x7f.0.0.1/ into 127.0.0.1 before your code sees a host name.1 Check the parsed host, because a comparison against the raw text misses both spellings.

export const DEFAULT_POLICY = { schemes: ['http:', 'https:'], ports: [80, 443] }

/** Parse the URL and apply the scheme and port allowlist. No name is resolved here. */
export function checkUrl(input, policy = DEFAULT_POLICY) {
  let url
  try {
    url = new URL(String(input))
  } catch {
    throw new BlockedUrlError('not a URL', String(input))
  }
  if (!policy.schemes.includes(url.protocol)) throw new BlockedUrlError('scheme not allowed', url.protocol)
  const port = Number(url.port || (url.protocol === 'https:' ? 443 : 80))
  if (!policy.ports.includes(port)) throw new BlockedUrlError('port not allowed', String(port))
  // The parser keeps the brackets on an IPv6 literal. The resolver and the socket want them off.
  const hostname = url.hostname.replace(/^\[(.*)\]$/, '$1')
  return { url, hostname, port }
}

The allowlist is a policy object rather than a constant, because an avatar importer and an internal webhook tester want different ports.

Resolve the name yourself and check every answer

A host name is not an address. Resolve it with dns.lookup and all: true, then check every address that comes back, because a name with one public and one private answer is a name an attacker gets to choose from. Node’s net.BlockList holds the ranges, and it checks an IPv4-mapped IPv6 address such as ::ffff:a9fe:a9fe against the IPv4 rules on its own.

export const BLOCKED_RANGES = [
  ['0.0.0.0/8', 'this network'],
  ['10.0.0.0/8', 'private'],
  ['100.64.0.0/10', 'shared address space'],
  ['127.0.0.0/8', 'loopback'],
  ['169.254.0.0/16', 'link-local, cloud metadata'],
  ['172.16.0.0/12', 'private'],
  ['192.0.0.0/24', 'IETF protocol assignments'],
  ['192.88.99.2/32', '6a44 relay anycast'],
  ['192.168.0.0/16', 'private'],
  ['198.18.0.0/15', 'benchmarking'],
  ['224.0.0.0/4', 'multicast'],
  ['240.0.0.0/4', 'reserved and broadcast'],
  ['::/128', 'unspecified'],
  ['::1/128', 'loopback'],
  ['64:ff9b::/96', 'NAT64, embeds an IPv4 address'],
  ['64:ff9b:1::/48', 'local-use NAT64, embeds an IPv4 address'],
  ['100::/64', 'discard-only'],
  ['100:0:0:1::/64', 'dummy prefix'],
  ['2001::/23', 'IETF protocol assignments, Teredo among them'],
  ['2001:db8::/32', 'documentation'],
  ['2002::/16', '6to4, embeds an IPv4 address'],
  ['3fff::/20', 'documentation'],
  ['5f00::/16', 'segment routing'],
  ['fc00::/7', 'unique local'],
  ['fe80::/10', 'link-local'],
  ['ff00::/8', 'multicast'],
]

The list holds every block that RFC 6890 does not mark as global, 6to4 and Teredo among them,2 as the IANA special-purpose registry keeps it current, minus the IPv4 documentation blocks. Those route nowhere, which is what lets 203.0.113.10 stand in for a public host in the tests.3 The three large clouds answer metadata requests on 169.254.169.254, a link-local address,4 and Google’s metadata server also answers on fd20:ce::254, which the fc00::/7 rule covers.

/** Resolve the name yourself and check every address it has. A literal address skips DNS. */
export async function resolveChecked(hostname, { lookup = dnsLookup } = {}) {
  const literal = isIP(hostname)
  const addresses = literal ? [{ address: hostname, family: literal }] : await lookup(hostname, { all: true })
  if (!addresses.length) throw new BlockedUrlError('no address', hostname)
  for (const { address, family } of addresses) {
    const rule = blockedBy(address, family)
    if (rule) {
      const where = literal ? address : `${hostname} resolves to ${address},`
      throw new BlockedUrlError('blocked address', `${where} in ${rule.cidr} (${rule.why})`)
    }
  }
  return addresses
}

The error names the address and the rule: log it, and send the caller a plain refusal.

Run the demo:

node demo.mjs
http://cdn.example.com/avatar.png                  fetched 200 from 203.0.113.10
http://cdn.example.com/moved                       fetched 200 from 203.0.113.10
http://169.254.169.254/latest/meta-data/           blocked address: 169.254.169.254 in 169.254.0.0/16 (link-local, cloud metadata)
http://[::ffff:169.254.169.254]/latest/meta-data/  blocked address: ::ffff:a9fe:a9fe in 169.254.0.0/16 (link-local, cloud metadata)
http://2130706433/                                 blocked address: 127.0.0.1 in 127.0.0.0/8 (loopback)
http://0x7f.0.0.1/                                 blocked address: 127.0.0.1 in 127.0.0.0/8 (loopback)
http://localhost/                                  blocked address: localhost resolves to 127.0.0.1, in 127.0.0.0/8 (loopback)
http://intranet.example.internal/                  blocked address: intranet.example.internal resolves to 10.1.2.3, in 10.0.0.0/8 (private)
http://mixed.example.com/                          blocked address: mixed.example.com resolves to 10.1.2.3, in 10.0.0.0/8 (private)
http://cdn.example.com:8500/v1/kv/                 port not allowed: 8500
ftp://cdn.example.com/                             scheme not allowed: ftp:
file:///etc/passwd                                 scheme not allowed: file:
http://cdn.example.com/go                          redirect 1: blocked address: 169.254.169.254 in 169.254.0.0/16 (link-local, cloud metadata)

Two of the thirteen URLs are fetched. The decimal and hex hosts arrive as 127.0.0.1, the IPv4-mapped literal is caught by the IPv4 rule, and mixed.example.com is refused for its second answer.

Connect to the address you checked, then check each redirect

Validation that ends by handing the host name to an HTTP client has checked one DNS answer and connected on another. The pin is the createConnection option of http.request: the socket goes to the checked address, and Node never resolves the name again. For TLS, host and servername carry the name, so the certificate is still checked against it; the test proves a certificate for another name fails with ERR_TLS_CERT_ALTNAME_INVALID.

    const req = client.request(url, {
      headers: { 'user-agent': 'link-preview/1.0' },
      // With no agent in play, Node needs told which port is the default or it writes
      // cdn.example.com:80 into the Host header.
      defaultPort: url.protocol === 'https:' ? 443 : 80,
      // The pin. The socket goes to the address that passed the check, so the name is never
      // resolved a second time. TLS still verifies the certificate against the name: host and
      // servername carry it, and the address carries nothing.
      createConnection: () => {
        const socket = dial(address, port)
        if (url.protocol !== 'https:') return socket
        const servername = isIP(hostname) ? undefined : hostname
        return tls.connect({ socket, host: hostname, servername, ...tlsOptions })
      },

Redirects get the whole procedure again. safeFetch never follows a Location itself. It resolves the header against the current URL and goes back to the top of the loop, so the next hop faces the same scheme, port, and address checks. A cap on the hop count ends the loop.

export async function safeFetch(input, { lookup, dial = dialDirect, maxRedirects = 3, policy, tls: tlsOptions } = {}) {
  let target = String(input)
  for (let hop = 0; ; hop++) {
    try {
      const { url, hostname, port } = checkUrl(target, policy)
      const [{ address }] = await resolveChecked(hostname, { lookup })
      const res = await request(url, { address, port, dial, tlsOptions })
      const location = res.headers.location
      if (res.status >= 300 && res.status < 400 && location) {
        if (hop === maxRedirects) throw new BlockedUrlError('too many redirects', target)
        target = new URL(location, url).href
        continue
      }
      return res

Location may be a relative reference under RFC 9110, so it is resolved against the URL that produced it rather than used as it arrived. safeFetch sets no timeout and reads the whole body. A production endpoint adds a timeout, as Time out fetch calls with AbortSignal in Node.js shows, and a cap on the body size.

Pin the address in other languages

Go’s http.Transport takes a DialContext function that receives the host and port and returns the connection. Resolve and check inside it, then dial the checked address; the transport runs TLS over that connection with the server name from the request URL. Set Client.CheckRedirect to return http.ErrUseLastResponse and run the redirect loop yourself. Python’s requests follows redirects by default and resolves names inside urllib3. Pass allow_redirects=False, check in your own code, and open an HTTPSConnectionPool on the checked address with the name in server_hostname and the Host header.

Check it worked

Run the suite. Its nine tests cover the two allowlists, the normalized hosts, the 6to4 and Teredo blocks, and a mixed name’s second answer. The rest cover the rebinding resolver, both redirect cases, the pinned TLS check, and a truncated body.

test('the connection goes to the checked address, so a rebinding resolver gains nothing', async () => {
  network.reset()
  const res = await fetchIt('http://evil.example.net/avatar.png')
  assert.equal(res.status, 200)
  assert.equal(res.address, PUBLIC_HOST)
  assert.deepEqual(network.state.lookups, ['evil.example.net'], 'one lookup, whose answer was checked')
  assert.deepEqual(network.state.dials, [`${PUBLIC_HOST}:80`])
})

test('a redirect is checked like a fresh URL', async () => {
  network.reset()
  await assert.rejects(fetchIt('http://cdn.example.com/go'), (err) => {
    assert.equal(err.reason, 'blocked address')
    assert.equal(err.detail, '169.254.169.254 in 169.254.0.0/16 (link-local, cloud metadata)')
    assert.equal(err.hop, 1)
    return true
  })
  assert.deepEqual(network.state.dials, [`${PUBLIC_HOST}:80`], 'only the first hop was dialed')
})
node --test ssrf.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 187.375309

# pass 9 and # fail 0 are the lines to look for. The rebinding test asserts one lookup and a dial to 203.0.113.10:80; a client that resolves twice fails the second assertion.

When it goes wrong

The request reaches the metadata service although the check passed. The code validated the name and then connected by name, so the HTTP client resolved it a second time, and the attacker’s resolver answered differently. Two clients against the same rebinding name:

node pitfall.mjs
evil.example.net answers 203.0.113.10 to the first query and 169.254.169.254 to the next

check the name, then connect by name
  lookups: evil.example.net, evil.example.net
  dialed:  169.254.169.254:80
  200 {"Code":"Success","Type":"AWS-HMAC","AccessKeyId":"AKIAIOSFO

check the name, then connect to the checked address
  lookups: evil.example.net
  dialed:  203.0.113.10:80
  200 PNG bytes for evil.example.net/avatar.png

The first client asked twice and dialed the second answer. The second asked once and dialed the address it had checked. The fix is the createConnection pin from the preceding step, or any client option that takes an address and a server name separately.

Your own CDN starts failing the check after the deploy. Split-horizon DNS answers a private address for a public name inside your network, so the check refuses it, correctly. Give known internal destinations a separate client with fixed addresses, never the endpoint that takes caller input.

A redirect to a blocked address goes through in production. The deployed code validates the URL, then hands it to the global fetch, which follows up to twenty redirects on its own and connects by name at every hop. The suite never exercised that path. The client that follows the redirect has to be the client that pins the address.

When not to do this

Do not treat in-process validation as the whole defense when the host holds credentials worth stealing. On AWS, require IMDSv2 and set the response hop limit to 1, or turn the endpoint off where nothing needs it. A bridged container then gets no token, whatever its code does. Add a network policy whose egress rule excludes 169.254.169.254/32, and the code on this page becomes the layer that explains refusals rather than the only layer.

Do not reach for a blocklist of names. localhost and metadata.google.internal are two spellings out of hundreds, and an address check covers every spelling by checking what the name becomes.

Do not build this for calls your own code makes to a fixed set of vendors. That traffic belongs behind an egress proxy with a per-vendor allowlist that is short and known in advance. The checks here exist for the opposite case, a destination the caller chooses.

Last verified

Verified 2026-10-21 against Node 22.22.0 and OpenSSL 3.0.13. Every output block is what the command preceding it printed. The public host, the metadata service, and the resolver are loopback stand-ins behind a dial function that records each address asked for, so no request left the machine. The Go and Python mechanisms were read from their documentation and not run.

Footnotes

  1. The URL Standard parses an IPv4 host one number at a time, reading a 0x prefix as hexadecimal and a leading zero as octal. When fewer than four numbers arrive, the last one supplies the remaining bytes, so 127.1, 0x7f.0.0.1, and 2130706433 are one host. The standard files the non-decimal spellings under a validation error named IPv4-non-decimal-part and parses them regardless. ↩︎ Back to text

  2. RFC 6890 gives each special-purpose block a table with a row headed Global. RFC 8190 replaced the word with Globally Reachable, because the earlier definition was misleading as it slightly differed from the generally accepted definition of global scope. The NAT64 prefix reads true, but the list blocks it for the IPv4 address it embeds, which 6to4 and Teredo addresses also carry. ↩︎ Back to text

  3. RFC 5737 provides 203.0.113.0/24 for documentation and asks network operators to add it to their packet filters, so an address in it passes a blocklist and is delivered nowhere. The first such block, 192.0.2.0/24, had carried the label TEST in RFC 1166 since 1990. A block reserved for examples is the one block a stand-in may use without impersonating anyone. ↩︎ Back to text

  4. RFC 3927 reserves 169.254/16 for hosts with no address server to ask, and forbids any device to forward a packet addressed into it, regardless of the TTL. Amazon, Google, and Microsoft each placed their instance metadata service at 169.254.169.254, an address that cannot leave the host. Google Cloud requires Metadata-Flavor: Google on every request, and Azure requires Metadata: true and refuses any request carrying X-Forwarded-For. AWS requires a token obtained with a PUT only on an instance set to IMDSv2 only, which is not the default. The headers exist because every process on the host can reach the address, the tricked one included. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.