The idea: the server fetches the URL for you

Many apps fetch a URL that a user typed: a link preview, a webhook, "import an image from a URL", a PDF renderer. The request then comes from the server, not from the user's browser. The server sits inside a private network, where it can reach things the internet cannot: the cloud's instance metadata service at 169.254.169.254, admin panels with no login, databases, localhost ports. If the app fetches whatever it is given, an attacker can point it at those. This is server-side request forgery (SSRF): the attacker writes the request, the server sends it with its own network position and trust.

The attacker on the internet sends POST /preview with url=http://169.254.169.254/... to the app server. The internet cannot reach the internal network directly, but the app server can: its fetcher sends GET to the metadata service, which returns the role's keys, and to admin.internal, which deletes a user. The app then shows the response to the attacker
The attacker cannot reach the internal network, but the app server can, and it fetches the attacker's URL.

The canvas has three regions: the internet (the attacker's browser on the app's preview form, a public site, the attacker's own web and DNS servers), the app server (the request, the guard that checks the URL, the VPC DNS resolver and the fetcher), and the internal network (the metadata service and an internal admin service). Pick a URL and press Fetch; the two selects change the app's guard and the metadata service version. The table at the bottom keeps every fetch.

What the attacker reaches

On AWS, GET http://169.254.169.254/latest/meta-data/iam/security-credentials/<role> returns temporary keys for the instance's IAM role. With IMDSv1 any plain GET from the machine gets them, so one preview of that URL hands the attacker the server's cloud identity (Demo 1). Google Cloud and Azure have the same kind of endpoint (metadata.google.internal, also at 169.254.169.254, needing a Metadata-Flavor or Metadata header). The second target in Demo 1 is an internal admin service that trusts every caller inside the network: a GET to admin.internal:8080/delete-user?id=7 deletes a user. Other common targets are localhost ports (Redis, Elasticsearch, a debug endpoint), Kubernetes and Docker APIs, and anything reached through file:// or gopher:// if the client supports those schemes.

Even when the response is not shown (blind SSRF), the attacker still learns from timing and error messages which hosts and ports exist, and a GET that changes state still does its damage.

Why a blocklist fails

The first fix most people write compares the host with a list of bad names. But an IP address has many spellings, and the HTTP client accepts them all. 2852039166 is 169.254.169.254 written as one 32-bit number (0xA9FEA9FE), so it is not on the list and still reaches the metadata service (Demo 2). Internal names like admin.internal are not on the list either.

spellingwhere it goes
2852039166169.254.169.254 (decimal)
0xa9fea9fe, 0251.0376.0251.0376169.254.169.254 (hex, octal)
127.1, 0, [::1]localhost
[::ffff:169.254.169.254]metadata, as an IPv4-mapped IPv6 address
anything.nip.io-style namesa public name whose DNS answer is a private IP
a public URL that answers 302wherever the redirect points

The lesson: check what the request will actually connect to, the IP address, not the text of the URL.

Check the IP, but only once: DNS rebinding

A better guard resolves the host and refuses private (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), loopback (127.0.0.0/8, ::1) and link-local (169.254.0.0/16, fe80::/10) addresses. It catches the decimal spelling, because it parses the host the same way the client does. But if it then hands the URL to the HTTP client, the client looks the name up again. The attacker owns the DNS server for rebind.attacker.example and answers with TTL 0, so nothing is cached: the first answer is a public address (the guard is happy), the second is 169.254.169.254 (the client connects there). This is DNS rebinding, a time-of-check to time-of-use bug (Demo 3).

Two timelines. Left, check then fetch: the guard asks DNS and gets 203.0.113.66, public, OK; the HTTP client asks DNS again and gets 169.254.169.254, connects to the metadata service and leaks keys. Right, resolve once and pin: the guard asks DNS once and gets 203.0.113.66, public, OK; the client connects to 203.0.113.66 and gets the attacker's harmless page
Two lookups give the attacker two answers; one lookup, checked and then used, gives only one.

The fix is to resolve once, check every returned address, and make the client connect to that IP (keeping the name only for the Host header and TLS). Rebinding then gets nothing but the attacker's own server, and the internal names and the normal link behave as expected (Demo 4). In code this means a custom resolver or socket hook in the HTTP client, or an egress proxy that does the check at connect time. The same check must run again on every redirect, or the client must not follow redirects.

Defences in layers

If the feature only needs a few destinations, an allowlist of hosts (and schemes and ports) is the simplest and strongest guard: everything else is refused before any DNS lookup (Demo 5). When users must be able to fetch arbitrary public URLs, use the resolve-check-pin approach, and add layers that do not depend on the app code being right:

layerwhat it doesstops
allowlist of hosts, https onlyrefuse every other destinationall of the above, when the feature allows it
resolve once, check, pin the IPconnect only to the checked public addressspellings, internal names, DNS rebinding
no redirects, or re-check each hopa 302 cannot move the request insideredirect tricks
egress firewall / proxythe fetcher's network can only reach the internetbugs in the app's own check
IMDSv2, hop limit 1metadata needs a PUT token and a headerGET-only SSRF reaching the keys
authentication on internal services"inside the network" is not a credentialthe admin call, even if reached
do not return raw responsesshow a title and an image, not the bodyreading data (blind SSRF still possible)

IMDSv2 deserves its own line. A caller must first send PUT /latest/api/token with an X-aws-ec2-metadata-token-ttl-seconds header, then pass the returned token in X-aws-ec2-metadata-token on every GET. A typical SSRF can only make a GET with no custom headers, so it gets 401 (Demo 6). The PUT response also has an IP hop limit of 1 by default, so a container behind an extra network hop cannot get a token. AWS recommends requiring IMDSv2 on every instance; it is a setting, so leaving IMDSv1 on is a misconfiguration.

Where it sits in OWASP

SSRF was its own entry, A10, in the OWASP Top 10 2021; in the 2025 list it is part of A01 Broken Access Control. It usually starts as a design flaw, a feature that trusts user input to choose a network destination (see OWASP A06:2025 Insecure Design), and it becomes a breach through configuration: IMDSv1 left on, flat internal networks, admin services without login (see OWASP A02:2025 Security Misconfiguration). The browser-side cousin is CSRF: there the attacker borrows the victim's cookies, here the server's network position. CORS does not apply at all, because no browser is involved in the server's request (see CORS and the Preflight Request).

What the page leaves out

Redirect chains, non-HTTP schemes (file://, gopher://, dict://) and smuggling other protocols through them, URL parser differences between the guard and the client (http://good.example@169.254.169.254/), IPv6 and dual-stack lookups that return several addresses, blind SSRF and its out-of-band detection, SSRF in PDF and image renderers, cloud-specific metadata headers, and network-level controls such as VPC endpoints and service meshes.