Defending Against SSRF

Why denylists fail, why allowlists work, and how to close the DNS rebinding gap between checking and connecting.

SSRF defence has one reliable strategy and several partial ones. Knowing which is which matters, because the partial ones look convincing.

Best: do not accept a URL

Ask for a file upload instead of a URL. Ask for an OAuth connection instead of an arbitrary endpoint. Restrict integrations to a set of known providers. Most "import from URL" features exist because it was easier than a file picker.

Next best: an allowlist of hosts

If only a handful of destinations are legitimate — a payment provider, a known CDN — resolve the host to an exact-match allowlist. This is the only approach with no known bypass class, because you are not trying to enumerate what is dangerous.

When the destination is genuinely arbitrary

Customer-supplied webhook endpoints are a real requirement. Then you need every one of these:

  1. Scheme allowlisthttps only. No file, gopher, dict, ftp.
  2. Resolve the DNS name yourself, then check every returned address against private, loopback, link-local, multicast and reserved ranges — IPv4 and IPv6.
  3. Connect to the resolved IP, passing the hostname for TLS and the Host header. This closes DNS rebinding, because there is no second lookup between validation and connection.
  4. Disable redirect following, or validate each hop with the same rules.
  5. Bound it — timeout, response size, and no credentials attached.

Why denylists lose

Decimal and octal IP notation, IPv6-mapped IPv4, 127.1, credentials-in-URL confusion (http://[email protected]/), redirects, and DNS rebinding. Every string-matching defence has a known bypass; address-based validation after resolution does not.

The strongest control is network-level

Run URL-fetching workloads in a network segment with no route to internal services and no route to the metadata endpoint. Then a bypass in your code reaches nothing. Application checks are defence in depth on top of that.

Example

Example · javascript
// The rebinding race, and why it works

// 1. You validate:      rebind.attacker.com  → 93.184.216.34   ✅ public
// 2. You then fetch:    rebind.attacker.com  → 169.254.169.254 ❌ internal
//
// The attacker's DNS server returns a different answer the second time,
// with a 1-second TTL. Nothing in your code is wrong except the ordering.

// The fix: one lookup, and connect to what you validated.
const { address } = await dns.promises.lookup(hostname);
assertPublicAddress(address);
await fetch(`https://${address}${path}`, {
  headers: { Host: hostname },       // virtual hosting still works
  servername: hostname,              // and TLS still validates
});

When to use it

  • A webhook delivery service validates the resolved address and pins the connection to it, closing a DNS rebinding bypass found in a pentest.
  • An image-import feature is replaced with a file upload, removing the SSRF surface entirely rather than defending it.
  • URL-fetching workers run in a subnet with no route to the metadata endpoint, so an application-level bypass reaches nothing.

More examples

A URL validator with no known bypass

Checking every resolved address rather than the first is important: a name that returns one public and one internal answer would otherwise pass validation.

Example · javascript
import dns from 'node:dns/promises';
import net from 'node:net';
import ipaddr from 'ipaddr.js';

const ALLOWED_SCHEMES = new Set(['https:']);
const ALLOWED_PORTS = new Set([443]);

// Ranges that must never be reachable. Both address families.
const BLOCKED_RANGES = [
  'unspecified', 'broadcast', 'multicast', 'linkLocal', 'loopback',
  'private', 'reserved', 'carrierGradeNat', 'uniqueLocal', 'ipv4Mapped',
  'rfc6145', 'rfc6052', '6to4', 'teredo',
];

export function assertPublicAddress(ip) {
  const parsed = ipaddr.parse(ip);

  // An IPv6-mapped IPv4 address must be judged on its IPv4 form:
  // ::ffff:169.254.169.254 is link-local, not "some ipv6 address".
  const effective = parsed.kind() === 'ipv6' && parsed.isIPv4MappedAddress()
    ? parsed.toIPv4Address()
    : parsed;

  const range = effective.range();
  if (BLOCKED_RANGES.includes(range)) {
    throw new BadRequestError(`address_not_allowed:${range}`);
  }

  // Belt and braces for the cloud metadata addresses specifically.
  const str = effective.toString();
  if (['169.254.169.254', '100.100.100.200', 'fd00:ec2::254'].includes(str)) {
    throw new BadRequestError('address_not_allowed:metadata');
  }
}

export async function resolveAndValidate(rawUrl) {
  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new BadRequestError('invalid_url');
  }

  // 1. Scheme and port
  if (!ALLOWED_SCHEMES.has(url.protocol)) throw new BadRequestError('scheme_not_allowed');
  const port = Number(url.port || 443);
  if (!ALLOWED_PORTS.has(port)) throw new BadRequestError('port_not_allowed');

  // 2. Credentials in the URL are a confusion trick — refuse them.
  if (url.username || url.password) throw new BadRequestError('credentials_in_url');

  // 3. A literal IP is validated directly; a name is resolved.
  const host = url.hostname.replace(/^\[|\]$/g, '');
  let addresses;
  if (net.isIP(host)) {
    addresses = [{ address: host }];
  } else {
    // ALL records — a name can resolve to several addresses, and ONE internal
    // answer is enough.
    addresses = await dns.lookup(host, { all: true, verbatim: true })
      .catch(() => { throw new BadRequestError('dns_resolution_failed'); });
  }
  if (!addresses.length) throw new BadRequestError('dns_resolution_failed');

  for (const { address } of addresses) assertPublicAddress(address);

  // 4. Return the address we validated. The caller connects to THIS,
  //    not to the hostname — that is what closes rebinding.
  return { url, host, address: addresses[0].address, port };
}

Fetching the validated address, with every bound

Never attaching cookies or Authorization to an outbound fetch is easy to overlook with a shared HTTP client, and it is what stops SSRF becoming credential theft.

Example · javascript
import https from 'node:https';

export async function safeFetch(rawUrl, { maxBytes = 5_000_000, timeoutMs = 5000 } = {}) {
  const { url, host, address, port } = await resolveAndValidate(rawUrl);

  return new Promise((resolve, reject) => {
    const req = https.request({
      host: address,              // ← connect to the VALIDATED ip
      port,
      path: url.pathname + url.search,
      method: 'GET',
      // Virtual hosting and certificate validation still work:
      headers: { Host: host, 'User-Agent': 'abc-fetcher/1.0', Accept: 'image/*' },
      servername: host,           // SNI + certificate hostname check
      timeout: timeoutMs,
      // No cookies, no Authorization, no internal headers. Ever.
    }, (res) => {
      // Redirects are NOT followed. A 302 to an internal address is the
      // simplest bypass of any URL validation.
      if (res.statusCode >= 300 && res.statusCode < 400) {
        res.destroy();
        return reject(new BadRequestError('redirects_not_followed'));
      }
      if (res.statusCode !== 200) {
        res.destroy();
        return reject(new BadRequestError(`upstream_status_${res.statusCode}`));
      }

      // Bound the response as it arrives — Content-Length is a claim.
      const chunks = [];
      let total = 0;
      res.on('data', (chunk) => {
        total += chunk.length;
        if (total > maxBytes) {
          res.destroy();
          return reject(new BadRequestError('response_too_large'));
        }
        chunks.push(chunk);
      });
      res.on('end', () => resolve({
        buffer: Buffer.concat(chunks),
        contentType: res.headers['content-type'],
      }));
    });

    req.on('timeout', () => { req.destroy(); reject(new BadRequestError('timeout')); });
    req.on('error', () => reject(new BadRequestError('fetch_failed')));
    req.end();
  });
}

// If you genuinely must follow redirects, validate EVERY hop with the same
// rules and cap the count:
export async function safeFetchFollowing(rawUrl, maxHops = 3) {
  let current = rawUrl;
  for (let hop = 0; hop <= maxHops; hop++) {
    const res = await safeFetchOnce(current);        // validates before connecting
    if (!res.redirectTo) return res;
    current = new URL(res.redirectTo, current).toString();   // re-validated next loop
  }
  throw new BadRequestError('too_many_redirects');
}

The network control that makes code bugs survivable

The egress proxy option is worth the effort at scale — it centralises the validation and produces the log you will want during an incident.

Example · bash
# Application checks can be bypassed. Network segmentation cannot be bypassed
# by a bug in your URL parser.

# ── Run URL-fetching work in its own segment ─────────────────────────
# docker-compose.yml
services:
  fetcher:
    image: url-fetcher:latest
    networks: [egress-only]        # NOT on the internal application network
    read_only: true
    cap_drop: [ALL]
    security_opt: [no-new-privileges:true]
    mem_limit: 256m

networks:
  egress-only:
    driver: bridge
    # No route to the app network, the database, or Redis.

# ── Block the metadata address at the host ───────────────────────────
iptables -A OUTPUT -d 169.254.169.254 -m owner --uid-owner fetcher -j REJECT

# ── Or in AWS: a private subnet with a NAT gateway and nothing else ──
#   fetcher subnet: 10.0.9.0/24
#   route table:    0.0.0.0/0 → NAT gateway    (internet, yes)
#                   10.0.0.0/8 → BLACKHOLE      (internal, no)
#   security group egress: 0.0.0.0/0:443 only
#   IMDSv2 required, hop limit 1

# ── Or an egress proxy, which gives you an audit trail too ───────────
#   fetcher has NO direct internet route.
#   All outbound traffic goes through squid/envoy, which:
#     - resolves and validates the destination itself
#     - denies RFC1918, loopback and link-local
#     - logs every request with the calling service
#     - enforces per-service allowlists where the destinations are known

# Now: a bypass in the URL validator reaches a network with no route to
# anything worth reaching. That is the difference between a bug and a breach.

Discussion

  • Be the first to comment on this lesson.