Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Don't Fetch Whatever URL They Give You

  • security
  • laravel
  • typescript
  • wordpress

Somebody asks for a small feature. "Let users paste an image URL and we'll pull it in." Or "show a preview card when someone shares a link." Or "let customers set their own webhook URL."

Totally reasonable. Also, you just built a tool that lets anyone on the internet make HTTP requests from inside your infrastructure.

That's server-side request forgery, SSRF. The name sounds exotic. The bug is usually one line: Http::get($request->input('url')).

Why your server is a better target than their laptop

When a user's browser fetches a URL, it can only reach what that user can reach.

When your server fetches it, it can reach what your server can reach. That often includes things that were never meant to be public:

  • localhost services like Redis, an admin panel, or a metrics endpoint
  • Other boxes on the private network (10.x, 172.16.x, 192.168.x)
  • The cloud metadata endpoint at 169.254.169.254, which on a misconfigured instance can hand out credentials

"It's internal, so it doesn't need auth" is a common assumption. SSRF is how that assumption becomes a problem.

Check the scheme, then check where it actually goes

Validating that the string looks like a URL is not enough. http://127.0.0.1:6379 is a perfectly valid URL. So is a public hostname whose DNS record points at a private address.

You have to resolve the host and check the IPs you'd actually connect to. In plain PHP:

function assertPublicUrl(string $url): void
{
    $parts = parse_url($url);
    $scheme = $parts['scheme'] ?? null;
    $host = $parts['host'] ?? null;
 
    if (! in_array($scheme, ['http', 'https'], true) || ! $host) {
        throw new InvalidArgumentException('Only http and https URLs are allowed.');
    }
 
    $ips = gethostbynamel($host) ?: [];
 
    if ($ips === []) {
        throw new InvalidArgumentException('That host does not resolve.');
    }
 
    foreach ($ips as $ip) {
        $public = filter_var(
            $ip,
            FILTER_VALIDATE_IP,
            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
        );
 
        if ($public === false) {
            throw new InvalidArgumentException('That address is not allowed.');
        }
    }
}

FILTER_FLAG_NO_PRIV_RANGE rejects the private ranges. FILTER_FLAG_NO_RES_RANGE rejects reserved ones like loopback and link-local, which is where the metadata endpoint lives. On PHP 8.2 and up, FILTER_FLAG_GLOBAL_RANGE is a stricter option worth looking at.

One honest caveat: gethostbynamel only returns IPv4 addresses. If your servers have IPv6 egress, you need to check AAAA records too (dns_get_record can get them).

Laravel: don't follow redirects you didn't check

The validation above checks the URL you were given. A redirect can send you somewhere else after the check passed.

So turn redirects off, and keep the timeout short while you're at it:

assertPublicUrl($url);
 
$response = Http::timeout(5)
    ->withoutRedirecting()
    ->get($url);

If you genuinely need redirects, follow them yourself: read the Location header, run it through the same check, and cap how many hops you'll take.

TypeScript: same idea with Node built-ins

Node has what you need without a package. dns/promises resolves every address, and net.BlockList does the range checks:

import { lookup } from "node:dns/promises";
import { BlockList } from "node:net";
 
const blocked = new BlockList();
blocked.addSubnet("0.0.0.0", 8);
blocked.addSubnet("10.0.0.0", 8);
blocked.addSubnet("127.0.0.0", 8);
blocked.addSubnet("169.254.0.0", 16);
blocked.addSubnet("172.16.0.0", 12);
blocked.addSubnet("192.168.0.0", 16);
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4-mapped addresses
 
export async function assertPublicUrl(raw: string): Promise<URL> {
  const url = new URL(raw);
 
  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error("Only http and https URLs are allowed.");
  }
 
  const addresses = await lookup(url.hostname, { all: true });
 
  for (const { address, family } of addresses) {
    if (blocked.check(address, family === 6 ? "ipv6" : "ipv4")) {
      throw new Error("That address is not allowed.");
    }
  }
 
  return url;
}

Then fetch without following redirects:

const url = await assertPublicUrl(input);
 
const res = await fetch(url, {
  redirect: "manual",
  signal: AbortSignal.timeout(5000),
});

The list above is a starting point, not gospel. Check it against the ranges that matter in your environment.

WordPress has a helper. Know what it covers.

If you're in WordPress, use wp_safe_remote_get() instead of wp_remote_get() for anything a user supplied. It runs the URL through wp_http_validate_url(), which rejects local and private addresses by default.

Look at what that function checks in your WordPress version before you treat it as the whole answer, especially for link-local addresses like the metadata endpoint.

The check has a gap. Close it at the network too.

Everything above has the same weakness: you resolve the hostname, then the HTTP client resolves it again when it connects. A hostile DNS server can answer "public" the first time and "private" the second. That's DNS rebinding.

You can pin the connection to the IP you checked, but that gets fiddly fast. The sturdier fix is to stop relying on application code alone:

  • Run user-driven fetches through an egress proxy, or from a worker on a network that can't reach your internal services.
  • On AWS, require IMDSv2. It needs a session token from a PUT request, so a plain GET to the metadata endpoint doesn't hand anything back.
  • Put real auth on internal services, even the "nobody can reach this anyway" ones.

Application checks catch the easy cases. Network rules catch the clever ones.

Don't hand the response straight back

Even when the fetch is safe, be careful with what you return. If your preview endpoint echoes the raw body, you've built a proxy. Pull out the fields you need (title, description, image URL), cap the response size, and check the content type you expected.

The habit

Any time a feature includes the words "from a URL," treat that URL as hostile.

Allow only http and https. Resolve the host and reject private, loopback, and link-local addresses. Don't follow redirects blindly. Keep timeouts short. And make sure the box doing the fetching couldn't reach anything interesting even if every one of those checks failed.

Your server will fetch whatever you tell it to. Make sure the user isn't the one telling it.

← All posts