Requests, files, and servers

Control server-side requests

Prevent SSRF by restricting destinations, protocols, redirects, DNS behavior, credentials, and response sizes for server-side fetch features.

A fetch made by your server runs from inside your network. It can reach the database admin panel, the internal metrics service, and the cloud metadata endpoint that hands out credentials. The user’s browser can reach none of that. SSRF, server-side request forgery, turns a URL field into a door into that private space.

The importer that trusts a URL

Here is an “import image from URL” feature in its most common form:

// Vulnerable: fetches wherever the user points
const image = await fetch(req.body.url)

Point it at http://169.254.169.254/latest/meta-data/iam/security-credentials/ on an AWS host and the response, your server’s own cloud credentials, gets saved as an “image” the attacker can download. Even a stricter version that checks the hostname first can be beaten. The attacker submits a public URL on their own domain, and that URL answers with a 302 redirect to the metadata address. The first host passes validation. The redirect doesn’t get checked.

Validate the address you connect to

Prefer fixed provider endpoints, like “import from Unsplash”, where there’s no URL to accept at all. When you must take a URL, allow only https:, resolve the hostname yourself, and reject private, loopback, and link-local ranges:

import dns from 'node:dns/promises'
import ipaddr from 'ipaddr.js'

async function assertPublic(hostname) {
  const { address } = await dns.lookup(hostname)
  const range = ipaddr.parse(address).range()
  if (['private', 'loopback', 'linkLocal', 'uniqueLocal'].includes(range)) {
    throw new Error('blocked destination') // 169.254.x, 10.x, 127.x, etc.
  }
  return address
}

Call assertPublic('169.254.169.254') and it throws blocked destination. Call it with images.unsplash.com and you get back a public address. A hostname like localhost resolves to 127.0.0.1, hits the loopback range, and fails too.

Then cap what the request can do. Don’t follow redirects, bound the time, and limit the bytes you read:

const res = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(3000) })

With redirect: 'manual' the 302 trick above returns the redirect response itself, status 302, instead of following it. You can then check the Location header with assertPublic again or just refuse redirects entirely. My advice is to refuse them. Legitimate image URLs rarely redirect.

DNS can change its mind

There’s one more trap. A hostname can resolve to a public address when you check it and to 127.0.0.1 a second later when fetch looks it up again. Attackers run DNS servers that do exactly this. The safest fix is to connect to the address you validated, not to the hostname, or to run the fetch through a client that lets you plug the check into the connection step itself. At minimum, keep the fetch client free of ambient credentials so a leaked request carries nothing useful.

Try this on your own project: feed the fetcher a public image, http://127.0.0.1:3000, http://10.0.0.5, http://169.254.169.254, and a URL on a domain you own that redirects to each of those. Log the decision for each and confirm no internal response ever gets stored.

Lesson completed