A surprising number of features boil down to fetch a URL the user gave us: generating a link preview, calling a webhook, importing an image from a link. When an assistant builds one, it writes the direct version that requests whatever URL comes in, because that is all the feature needs to work. The trouble is that your server can reach places an outside attacker cannot, and a fetch-anything feature hands them that reach.
How the attack works
The attacker supplies a URL that points not at a normal website but at an internal address: a service bound to localhost, an admin panel on your private network, or the cloud metadata endpoint that returns your instance's credentials. Your server, doing exactly what it was told, fetches that address from inside the network and often hands the response back.
// the vulnerable pattern: fetch whatever URL arrives const r = await fetch(req.body.url); const data = await r.text(); // attacker sends a URL pointing at the cloud metadata address, // and your server retrieves credentials meant to be internal
The metadata case is the one that turns a small feature into a serious breach, because the response can include keys that unlock your cloud account. The concept in full is in what SSRF is.
Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.
See a live scan →Why a scheme check is not enough
It is tempting to validate that the URL starts with http and call it done, but that stops nothing here. A valid http URL can point straight at an internal address. What matters is not how the URL is spelled but where it resolves, so the check has to happen on the resolved destination, not the string.
The fix: control the destination
Where you can, use an allowlist of the hosts you actually expect and reject everything else. Where the URL is genuinely open, resolve the hostname and block private, loopback, and link-local ranges, restrict the scheme and port, and follow redirects manually so you can re-check each hop, since a public host can otherwise redirect you to an internal one.
const url = new URL(req.body.url);
if (!ALLOWED_HOSTS.has(url.hostname)) {
return res.status(400).json({ error: "URL not allowed" });
}
const r = await fetch(url);The allowlist version is the simplest to reason about when you know the hosts. The full walkthrough, including the resolve-and-block approach for open URLs, is in the fix for an SSRF-prone URL fetch. It is worth adding this to a standing review pass, since any new fetch-a-URL feature is a candidate.
Frequently asked questions
What makes SSRF different from a normal outbound request?
The difference is who chooses the destination. A normal outbound request goes to a URL your code decided on. In SSRF the attacker chooses the URL, and because the request comes from inside your network, it can reach things a random outsider cannot: internal admin panels, databases bound to localhost, and cloud metadata endpoints. Your server becomes a proxy the attacker aims at your own infrastructure.
Why is the cloud metadata endpoint such a common target?
Because on many cloud providers a special internal address returns credentials and configuration for the running instance, and it is only reachable from the instance itself. An SSRF bug lets an attacker make your server request that address and hand back the response, which can include keys that grant access to your cloud account. It turns a link-preview feature into a credential leak, which is why blocking internal addresses is the core of the fix.
Isn't checking that the URL starts with http enough?
No. The scheme check does not stop an attacker from giving a perfectly valid http URL that points at an internal address, like your metadata endpoint or a service on localhost. You have to inspect where the URL actually resolves, not just how it is spelled. Resolve the hostname and reject private, loopback, and link-local addresses, and re-check after any redirect so a public host cannot bounce you to an internal one.
What is the safest way to build a fetch-a-URL feature?
Where you can, use an allowlist of the specific hosts you expect, and reject everything else. Where the URL is genuinely open, resolve the destination and block private and reserved address ranges, disallow non-http schemes and unexpected ports, follow redirects manually so you can re-check each hop, and cap the response size and time. The goal is that the server only ever fetches public destinations you would be comfortable proxying.
Find the fetches that trust their input
Any request built from a user-supplied URL is worth checking, and a scan flags the pattern by shape so you do not have to trace every fetch by hand. A scan reads the repo and points at the line, so you can add the guard before someone aims it at your metadata endpoint. Run a free scan and see whether any fetch in your app trusts its input.