How do I prevent SSRF?

Direct answer

Use an allowlist of permitted hosts rather than trying to block bad ones. If you must accept arbitrary URLs, resolve the hostname to an IP first, reject private and link-local ranges, and re-check after every redirect. Blocklists fail because there are too many ways to express the same address.

Muhammad HasanUpdated

Server-side request forgery is when your server fetches a URL that a user controls. Webhook configuration, image imports, link previews, PDF generation, anything taking a URL as input.

The risk is that your server sits inside a network the user can't reach. Cloud metadata endpoints, internal admin panels, databases bound to localhost, all reachable from your server, none from the internet.

// Vulnerable
app.post('/preview', async (req, res) => {
  const page = await fetch(req.body.url);
  res.send(await page.text());
});

Allowlist first

If you can enumerate the hosts you need to reach, do that and stop.

const ALLOWED = new Set(['api.stripe.com', 'hooks.slack.com']);

const url = new URL(req.body.url);
if (!ALLOWED.has(url.hostname)) throw new Error('Host not permitted');
if (url.protocol !== 'https:') throw new Error('HTTPS only');

This covers most real cases. Webhooks to arbitrary customer endpoints are the main exception.

Why blocklists fail

The instinct is to reject localhost and 127.0.0.1. That doesn't hold, because the same address has many representations: decimal 2130706433, octal, IPv6 [::1], IPv4-mapped IPv6, a domain that resolves to a private address, a shortened URL that redirects to one.

You cannot enumerate the ways to write an address. You can check what an address actually resolves to.

Resolve, then check

import dns from 'dns/promises';
import net from 'net';

function isPrivate(ip) {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split('.').map(Number);
    return a === 10
      || a === 127
      || (a === 172 && b >= 16 && b <= 31)
      || (a === 192 && b === 168)
      || (a === 169 && b === 254);   // link-local, incl. cloud metadata
  }
  return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd');
}

const { address } = await dns.lookup(new URL(input).hostname);
if (isPrivate(address)) throw new Error('Blocked');

Note 169.254.169.254 specifically. That's the cloud metadata endpoint on AWS, GCP and Azure, and on IMDSv1 it hands out credentials to anything that asks. If you're on AWS, enforce IMDSv2. It requires a header and a token, which an SSRF through a simple GET can't supply.

Redirects

Validating the URL you were given and then following redirects means validating nothing. The first response can point anywhere.

const res = await fetch(url, { redirect: 'manual' });

Handle redirects yourself, re-validating each hop, or disable them.

There's also a race between the DNS check and the request, a hostname can resolve differently the second time. Pinning the request to the IP you validated closes it, at the cost of some complexity.

The other layer

Network egress rules are the durable fix. A service that doesn't need to reach the internal network shouldn't be able to, regardless of what the application code does.

Application-level validation and network-level restriction are both worth having. The second one survives someone forgetting the first.

Go deeper

Fast remediation with Kolega

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free