Quick Answer

Server-Side Request Forgery (SSRF) happens when an attacker tricks your server into making an HTTP request to a destination it chose, not the one you intended. Because the request comes from your server, it can reach internal systems, admin panels, databases, cloud metadata endpoints, that are normally walled off from the public internet. The fix is never trusting the URL string alone: resolve it and validate the actual IP against an allowlist.

What SSRF Actually Is

Any feature that fetches a URL on the user's behalf is a candidate for SSRF: an image proxy that downloads an avatar from a link, a "preview this webpage" card, a webhook tester, a PDF generator that renders a page you point it at, a service that imports data "from URL". The user supplies a URL, and the server, not the user's browser, makes the actual request.

That's the whole vulnerability. The server usually sits inside a private network, behind a firewall, with access to things the public internet cannot reach directly: internal admin dashboards, databases, other microservices, and, on every major cloud provider, a metadata endpoint that hands out temporary credentials to whatever is running on that machine.

If the server will fetch any URL the user provides, the user can point it at those internal addresses instead of a real external site. The server, trusted by the internal network, walks right in and hands the response back.

The Vulnerable Code

Here's the pattern, stripped to its essence, an endpoint that fetches whatever URL it's given and returns the response:

app.get('/fetch', async (req, res) => {
  const target = req.query.url;
  const upstream = await fetch(target); // no validation at all
  res.json({ body: await upstream.text() });
});

This looks harmless for its intended use, "fetch http://example.com", and works exactly as expected. Nothing here checks where that URL actually points. The code trusts the string the same way it would trust a filename or a search query, but a URL is an instruction to connect somewhere, and "somewhere" is not scoped to the public internet by default.

Code like this tends to survive review because nothing about it looks unusual: there's no string concatenation into a query, no eval, none of the patterns a linter or a security scanner is tuned to flag. The mistake is conceptual rather than syntactic. The developer validated that a URL was present, not that the destination was one the server should be allowed to reach on the caller's behalf.

The Real Attack, Run Live

I built exactly this: a public-facing fetch endpoint next to a second server on the same host simulating an internal-only endpoint, the kind of thing a cloud metadata service or an internal admin API would expose. Running the vulnerable endpoint normally works as intended:

GET /fetch?url=http://example.com
Response status: 200

Then, pointing the same endpoint at the "internal" server instead of a real external site:

GET /fetch?url=http://127.0.0.1:4001/latest/meta-data/iam/security-credentials/admin-role
Response status: 200
Response body: {"AccessKeyId":"FAKE-ACCESS-KEY-DEMO","SecretAccessKey":"FAKE-SECRET-DO-NOT-USE","Token":"FAKE-SESSION-TOKEN"}

No exploit, no injection, no malformed input, just a URL the endpoint was never supposed to be pointed at. In production that credential response is real, and an attacker who steals it can often act as your application inside your own cloud account.

The stolen credentials here are fake, but a real IAM role attached to a production instance usually carries far more permission than the one feature that leaked it actually needs, since roles tend to be scoped to the whole service rather than a single endpoint. An attacker holding them can often read other data stores, provision new resources, or move laterally through the account entirely outside the application they originally attacked.

The Fix: Resolve, Then Check the IP

Blocking obvious strings like "localhost" or "169.254" is not enough. A hostname is just a label, and DNS can point any name at any address, including a private one, after your string check has already passed. This is called DNS rebinding. The fix has to validate the address the request will actually go to, not the name it arrived with:

const { address } = await dns.lookup(parsed.hostname);
if (isPrivateOrReservedIp(address)) {
  throw new Error('blocked: resolves to private IP ' + address);
}
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
  throw new Error('blocked: host not on allowlist');
}

Running the same two requests against this fixed version:

GET /fetch?url=http://example.com
Response status: 200

GET /fetch?url=http://127.0.0.1:4001/latest/meta-data/...
Response status: 400
Response body: {"error":"blocked: host \"127.0.0.1\" is not on the allowlist"}

The legitimate request still succeeds; the attack is rejected before any connection to the internal server is even attempted.

What an Allowlist Doesn't Cover

An allowlist of hostnames is necessary but not sufficient by itself. A few gaps show up repeatedly in real audits:

  • Redirects. An allowed domain can respond with a 302 that redirects to an internal address. If your HTTP client follows redirects automatically, re-validate the destination after every hop, or disable auto-follow entirely.
  • Unusual schemes. file://, gopher://, and dict:// have all been used to turn a URL-fetching feature into something far worse than SSRF. Restrict to http and https explicitly.
  • Infrastructure-level egress rules. Cloud security groups that block outbound traffic from application servers to the metadata address and internal CIDR ranges catch mistakes the application code misses. Treat the allowlist as the primary control and network policy as the backstop, not the other way round.

Where SSRF Hides in Real Apps

SSRF rarely announces itself as "fetch a URL". It hides inside features that sound completely unrelated to security: an avatar upload that accepts an image link instead of a file, a link-preview card that unfurls whatever URL a user pastes into chat, a headless-browser screenshot or PDF service that renders any page it's pointed at, a webhook "send a test event" button, and server-side OAuth callback handlers that fetch a token endpoint URL supplied in configuration. Any of these is worth a second look if the destination is ever attacker-influenced.

This isn't theoretical. The 2019 Capital One breach, one of the largest financial data breaches on record, traced back to exactly this pattern: a misconfigured web application firewall let an attacker send a request that reached AWS's metadata endpoint through the application, retrieved temporary credentials, and used them to pull data the application's role had access to. No password was stolen. The application simply fetched a URL it should never have been allowed to reach.

Frequently Asked Questions

What is SSRF in simple terms? It's tricking a server into fetching a URL you chose instead of the one it expected, so it makes a request to somewhere it shouldn't, often an internal system the attacker can't reach directly themselves.
Why do attackers target 169.254.169.254? It's the standard address for a cloud instance's metadata service, which hands out temporary credentials to whatever code is running on that machine. It's reachable only from inside, which is exactly where a vulnerable server sits.
Does checking the hostname string stop SSRF? No. DNS can resolve any hostname to any IP, including a private one, after your check runs. You must resolve the address and validate the actual IP, not just the name in the URL.
Can SSRF happen over HTTPS too? Yes. The protocol doesn't matter. SSRF is about which address the server connects to, not whether that connection happens to be encrypted.
Is an allowlist enough by itself? It's the core defense, but pair it with blocking private and reserved IP ranges after DNS resolution, disabling automatic redirects, and restricting outbound network access at the infrastructure level.