# 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.

Source: https://voxgig.com/howto/defend-a-url-fetching-endpoint-against-ssrf

- Audience: api-producer
- Level: advanced
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://api-security.owasp.org/editions/2023/en/0xa7-server-side-request-forgery/).
The OWASP
[cheat sheet on preventing it](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html)
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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A network egress allowlist with no metadata route](https://kubernetes.io/docs/concepts/services-networking/network-policies/) | A platform you control end to end, where the metadata service can be switched off or hop-limited | Rules by address and port only, so every legitimate destination must be listed and a redirect to an attacker's public host still goes through | The endpoint must reach arbitrary public hosts, which is what a link previewer is for |
| [In-process allowlist validation](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) | One service with one fetching endpoint, shipped this week | Code that every fetching path must call, and a bypass the moment one path calls `fetch` directly | Several services fetch caller URLs, or the host holds credentials worth stealing |
| [stripe/smokescreen egress proxy](https://github.com/stripe/smokescreen#readme) | Several services fetch caller URLs and you want one enforcement point with its own log | A Go service to deploy, scale, and keep patched, plus a client identity to issue to every caller | One 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.

```js title="ssrf.mjs"
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`](https://nodejs.org/api/net.html#class-netblocklist)
holds the ranges, and it checks an IPv4-mapped IPv6 address such as `::ffff:a9fe:a9fe` against
the IPv4 rules on its own.

```js title="ssrf.mjs"
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](https://www.rfc-editor.org/rfc/rfc6890.html) does not
mark as global, 6to4 and Teredo among them,[^2] as the
[IANA special-purpose registry](https://www.iana.org/assignments/iana-ipv4-special-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.

```js title="ssrf.mjs"
/** 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:

```bash
node demo.mjs
```

```text output
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`](https://nodejs.org/api/http.html#httprequestoptions-callback): 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`.

```js title="ssrf.mjs"
    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.

```js title="ssrf.mjs"
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](https://www.rfc-editor.org/rfc/rfc9110.html#name-location), 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](/howto/time-out-fetch-with-abortsignal-in-node)
shows, and a cap on the body size.

## Pin the address in other languages

Go's [`http.Transport`](https://pkg.go.dev/net/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`](https://pkg.go.dev/net/http#Client) 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`](https://requests.readthedocs.io/en/latest/user/quickstart/), check in
your own code, and open an
[`HTTPSConnectionPool`](https://urllib3.readthedocs.io/en/stable/advanced-usage.html#custom-sni-hostname)
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.

```js title="ssrf.test.mjs"
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')
})
```

```bash
node --test ssrf.test.mjs
```

```text output
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:

```bash
node pitfall.mjs
```

```text output
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](https://fetch.spec.whatwg.org/#http-redirect-fetch) 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](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-options.html)
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.

## Related how-tos

- [Route outbound API calls through an egress proxy](/howto/route-outbound-calls-through-an-egress-proxy)

- [Review an API against the OWASP API Security Top 10](/howto/review-an-api-against-owasp-api-top-ten)

- [Tell a denied outbound call apart from a vendor outage](/howto/diagnose-an-outbound-call-that-was-denied)

## 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.

[^1]: The [URL Standard](https://url.spec.whatwg.org/#concept-ipv4-parser) 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.

[^2]: [RFC 6890](https://www.rfc-editor.org/rfc/rfc6890.html) gives each special-purpose block
a table with a row headed Global. [RFC 8190](https://www.rfc-editor.org/rfc/rfc8190.html)
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.

[^3]: [RFC 5737](https://www.rfc-editor.org/rfc/rfc5737.html) 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](https://www.rfc-editor.org/rfc/rfc1166.html) since 1990. A block
reserved for examples is the one block a stand-in may use without impersonating anyone.

[^4]: [RFC 3927](https://www.rfc-editor.org/rfc/rfc3927.html) 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`](https://docs.cloud.google.com/compute/docs/metadata/overview)
on every request, and Azure requires
[`Metadata: true`](https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service)
and refuses any request carrying `X-Forwarded-For`. AWS requires
[a token obtained with a PUT](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#instance-metadata-v2-how-it-works)
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.