Any feature that takes a user-supplied URL and fetches it server-side runs that fetch with the server’s identity, network position and credentials. A thumbnailer, a webhook tester, an importer, a PDF renderer — the moment the target comes from the request, the attacker is not visiting your internal network. Your server is visiting it for them.

What makes this outrank most injection bugs is reachability. The server sits inside the perimeter: it can open the loopback admin panel, the RFC 1918 services, and the link-local address serving instance credentials. The only thing between an attacker and that network is the code deciding which URLs are acceptable — usually a string comparison.

The fetch carries the server’s authority

The classic target is the instance metadata service. On AWS it answers on 169.254.169.254, and GET /latest/meta-data/iam/security-credentials/ returns the instance’s IAM role name, followed by that role’s temporary credentials. Those credentials are what the instance uses for every AWS API call, so an SSRF that reaches the metadata endpoint does not read a config file — it hands over the instance’s cloud identity. OWASP lists the same shape for internal REST interfaces and HTTP-speaking databases that carry no authentication because they were only meant to be reachable from inside.

The fixture measures both halves. Against a local stand-in it drives the hex form of 127.0.0.1 through a check that inspects the URL string, which reports ALLOWED, and the fetch returns a credential document. Then it measures the hardening: a v2-only instance answers a bare GET with 401, releasing metadata only when the request carries X-aws-ec2-metadata-token, which the attacker must first obtain with a PUT /latest/api/token. A single-shot SSRF cannot complete that handshake.

A URL is not an address

String checks fail because the string is not the address the client connects to. The same loopback address has many spellings: http://2130706433/ is decimal, http://0x7f000001/ is hex, http://127.1/ is a short form, http://[::1]/ covers the IPv6 side. The fixture resolves every one through getaddrinfo and gets 127.0.0.1 back, while a substring check for 127.0.0.1 allows all of them.

The @ is worse, because it makes an allowlist pass. In http://trusted.example@169.254.169.254/ the userinfo is trusted.example and the host is 169.254.169.254. RFC 3986 separates them, but a regex that captures everything after the scheme and tests for the allowlisted name sees the name and approves. The fixture’s regex allowlist reports ALLOWED for that URL while urlsplit reports the real host. Redirects and DNS rebinding are the same idea on a time axis: the address is validated, then something changes before the connection.

Validate after resolution, then pin the connection

The order matters more than the list. Parse with a real parser, allowlist the scheme, host and port, resolve the hostname, and classify every address that comes back — rejecting loopback, private, link-local, reserved and multicast ranges. Then connect to the address you validated rather than resolving again, or a rebinding name returns a public address to the check and a private one to the socket. The fixture’s resolve-then-classify function rejects all seven probe URLs, including the ones the string checks allowed.

Redirects need the same treatment on every hop: disable them, or re-run the guard on each Location before the client requests it. IMDSv2 is the second layer, since it makes a bare GET useless, and a network policy stopping the workload from reaching 169.254.169.254 is the third. None substitutes for the others: the allowlist decides intent, address classification decides reachability, the egress rule decides possibility.

When it breaks

The first failure is a fetcher that trusts the string. It presents as a 200 where you expected a rejection, or a body that looks like neither your application nor a normal error: a credential document, an admin page, a database’s REST reply. In the fixture the naive check reports ALLOWED for the hex loopback URL and the fetch returns the credential JSON — the attack in two lines, no exploit payload, just a URL the guard misread.

The second failure is the guard that validates once. A redirect on an allowlisted host, or a name whose answer changes between check and connect, walks past a single pre-flight validation. The fixture follows the redirect hop and gets the metadata body back, while a client re-validating each hop refuses it. The blast radius is worst when the target is an internal control plane with no authentication, precisely because it believed only localhost could reach it.

Check it quickly

Probe your own fetcher with the shapes a string check misses:

curl -s -o /dev/null -w 'allowlisted  -> %{http_code}\n' 'https://app.example.com/api/fetch?url=https://cdn.example.com/logo.png'
curl -s -o /dev/null -w 'imds         -> %{http_code}\n' 'https://app.example.com/api/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/'
curl -s -o /dev/null -w 'decimal      -> %{http_code}\n' 'https://app.example.com/api/fetch?url=http://2130706433:8080/admin'
curl -s -o /dev/null -w 'userinfo     -> %{http_code}\n' 'https://app.example.com/api/fetch?url=http://trusted.example@169.254.169.254/'

The first must succeed, because a guard that blocks everything is an outage rather than a guard. The other three must come back 4xx with no body from the target; a 200 means the fetcher decided from the URL string. A 200 carrying a credential document, an internal page or an echoed connection error is a confirmed SSRF.

Sources