SSRF: when an API fetches a client-supplied URL Intermediate
Estimated reading time: 10 min read
Symptoms
Section titled “Symptoms”The application has an “enter an avatar URL”, “link preview” or “register a webhook” endpoint. The client sends a URL, the server calls fetch(url) and processes the content. Logs show requests to 127.0.0.1, private addresses in the VPC, 169.254.169.254, or a “normal” hostname that resolves to an internal IP.
That is SSRF (Server-Side Request Forgery, item API7:2023 of the OWASP API Security Top 10): the attacker controls the destination of a request the server makes.
The request goes out from the server’s network position, which can see admin UIs, the metadata service and internal APIs. If the server returns the body to the client, internal data has a path out.
URLThe client sends a URL for the API to fetch an image from; the URL is untrusted data.
A common target: the metadata endpoint
Section titled “A common target: the metadata endpoint”In the cloud, every VM can call the metadata service at the link-local address 169.254.169.254 (EC2 also has fd00:ec2::254 for IPv6 on Nitro). It contains the temporary credentials of the IAM role attached to the instance.
SSRF to this address is the shortest path from “fetch a URL” to “having permissions on the cloud account”.
AWS has two versions:
| IMDSv1 | IMDSv2 | |
|---|---|---|
| How it’s called | A plain GET |
PUT /latest/api/token (with X-aws-ec2-metadata-token-ttl-seconds, up to 6 hours), then GET with the X-aws-ec2-metadata-token header |
| Why it’s hard to exploit via SSRF | One GET is enough |
Needs a PUT and a custom header, while typical SSRF only controls the URL |
| Hop limit | Not applicable | Usually defaults to 1 for the PUT response (2 if the AMI sets ImdsSupport: v2.0); the token struggles to pass through an extra proxy or container hop |
| Default | Both enabled, unless the AMI (ImdsSupport: v2.0) or the account default has chosen IMDSv2 |
Can be configured to accept only IMDSv2 (HttpTokens=required) |
Rule: require IMDSv2 and disable IMDSv1 on every instance running code that accepts URLs from users. Containers that need to call metadata may need a higher hop limit; only do this when truly necessary.
IMDSv2 is a mitigation layer, not a substitute for validation. Other clouds have similar endpoints (for example metadata.google.internal); read each provider’s docs.
Reproduction
Section titled “Reproduction”The original demo opens a temporary HTTP server on 127.0.0.1 with a fake internal-secret response, then calls an unchecked fetch and a validateKnownHost() function:
npx --prefix examples ts-node examples/src/ssrf/local-ssrf-demo.tsThe unchecked fetch reads the string from the local server; validateKnownHost() rejects 127.0.0.1 and accepts https://images.example.com/.... This function only checks the hostname string, so it does not yet defend against redirects or DNS rebinding. The second demo (examples/src/ssrf/safe-fetch-demo.ts) covers that part, with output in the sections below.
Why a localhost denylist is not enough
Section titled “Why a localhost denylist is not enough”One IP, many spellings
Section titled “One IP, many spellings”URL parsers normalize many forms to the same address. Real output from the demo (new URL(raw).hostname, then a range check):
http://127.0.0.1/ -> 127.0.0.1 BLOCKEDhttp://2130706433/ -> 127.0.0.1 BLOCKEDhttp://0x7f.1/ -> 127.0.0.1 BLOCKEDhttp://0177.0.0.1/ -> 127.0.0.1 BLOCKEDhttp://[::ffff:127.0.0.1]/ -> ::ffff:7f00:1 BLOCKEDhttp://[::1]/ -> ::1 BLOCKEDhttp://169.254.169.254/ -> 169.254.169.254 BLOCKEDhttp://8.8.8.8/ -> 8.8.8.8 allowedLesson: check the parsed and resolved address, not the string. Decimal, hex, octal and IPv4-mapped IPv6 forms (::ffff:127.0.0.1, which the parser normalizes to ::ffff:7f00:1) all point to loopback; a string comparison against '127.0.0.1' misses them.
Another library may parse differently from Node, so OWASP recommends rejecting URLs that the parsers in the chain interpret differently.
Address ranges to block
Section titled “Address ranges to block”| Range | Meaning |
|---|---|
127.0.0.0/8, ::1/128 |
Loopback |
0.0.0.0/8, ::/128 |
“This host” / unspecified, which many systems treat as localhost |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
RFC1918 private |
169.254.0.0/16, fe80::/10 |
Link-local, including the 169.254.169.254 metadata address |
fc00::/7 |
IPv6 unique local |
100.64.0.0/10 |
Carrier-grade NAT, often used in cloud internal networks |
::ffff:0:0/96 |
IPv4-mapped IPv6: strip the mapping layer, then check against the IPv4 table |
224.0.0.0/4, ff00::/8 |
Multicast |
The 100.64.0.0/10, ::/128 and ::ffff:0:0/96 ranges are additions by this article; OWASP lists loopback, private, link-local, unique local and multicast. A denylist is still the fallback option: OWASP states clearly that deny-lists are easy to bypass, so prefer an allowlist when the destinations are known.
Redirects and DNS rebinding
Section titled “Redirects and DNS rebinding”The two most common detours when only the initial URL is validated:
- Redirect: an allowed host returns
302 Location: http://127.0.0.1/admin. If the client follows automatically, the next request goes to a forbidden destination. - DNS rebinding: the hostname resolves to a public IP at check time, then to an internal IP at connect time, because the attacker controls DNS and sets a very low TTL.
The attacker submits a URL whose hostname they control, including its DNS and TTL.
Real output from safe-fetch-demo.ts (random port on every run). First, redirects: the client follows by default, while redirect: 'manual' returns the 302 response as is:
redirect: follow -> internal-secretredirect: manual -> 302 http://127.0.0.1:64768/adminNext, a resolver simulating rebinding (public IP the first time, loopback the second). Resolving twice gives two answers; the pinning approach resolves only once and connects to the validated IP:
check-then-fetch (resolve twice): 203.0.113.9 then 127.0.0.1pinned (resolve once): connect to 203.0.113.9 (validated IP) -> timeoutresolve count: 1(203.0.113.9 belongs to the TEST-NET-3 range reserved for documentation, so the connection times out; the point is that the client never reaches 127.0.0.1.)
Safe fetch: the skeleton
Section titled “Safe fetch: the skeleton”The procedure when you must fetch arbitrary URLs:
Loading diagram…
The skeleton in Node (https, abridged; the IP classification lives in safe-fetch-demo.ts):
import { request } from 'node:https';import { lookup } from 'node:dns/promises';
async function safeGet(raw: string, hops = 0): Promise<Buffer> { if (hops > 3) throw new Error('too many redirects'); const url = new URL(raw); if (url.protocol !== 'https:' || url.username || url.password) throw new Error('not allowed'); if (url.port && url.port !== '443') throw new Error('port not allowed');
const addrs = await lookup(url.hostname, { all: true }); // resolve once if (addrs.length === 0 || addrs.some((a) => isBlockedIp(a.address))) throw new Error('blocked'); const pinned = addrs[0];
const res = await new Promise<import('node:http').IncomingMessage>((ok, fail) => { const req = request(url, { timeout: 5000, // force the socket to use the validated IP; Host/SNI stay the original hostname lookup: (_h, opts, cb) => (opts.all ? cb(null, [pinned] as never) : cb(null, pinned.address, pinned.family)), }, ok); req.on('timeout', () => req.destroy(new Error('timeout'))); req.on('error', fail).end(); });
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return safeGet(new URL(res.headers.location, url).toString(), hops + 1); // validate again from scratch } const chunks: Buffer[] = []; let size = 0; for await (const c of res) { size += c.length; if (size > 1_000_000) throw new Error('too large'); chunks.push(c); } return Buffer.concat(chunks);}The decisive points: use a custom lookup so the socket connects to the exact IP that was checked, and handle redirects yourself so every hop starts over.
The exception: if you only call a known set of hosts, drop all of this and use an exact allowlist, building the URL yourself with a fixed scheme, port and path.
Egress policy: the last layer
Section titled “Egress policy: the last layer”Validation in code can miss things; the network is a layer that doesn’t depend on code:
- Run workers that fetch arbitrary URLs in a separate subnet, with a security group or
NetworkPolicythat only allows the public Internet and blocks private and link-local ranges. - Use an outbound proxy with a policy (and logging) for every outgoing request from this workload.
- Require IMDSv2, or block metadata entirely for workloads that don’t need it.
- Grant a minimal IAM role: if credentials leak, the blast radius stays small.
Real-world examples
Section titled “Real-world examples”Production checklist
Section titled “Production checklist”- List every place where outbound requests are built from input: avatar importers, link previews, PDF/screenshot renderers, webhook senders, image proxies.
- If an allowlist is possible, use an exact allowlist; build the scheme, port and path yourself.
- Only allow
http/httpsand the ports you need; reject URLs with userinfo. - Resolve once, check every A/AAAA (including IPv4-mapped), connect to the exact validated IP.
- Redirects: disable automatic following; validate every hop again, with at most a few hops.
- Set timeouts, body size limits and concurrency limits for fetch workers.
- Egress policy at the network layer; IMDSv2 required; minimal IAM roles.
- Log the hostname, connected IP, port and hop count; alert on requests to private/link-local ranges.
- Test in an isolated environment with fake servers: loopback, private, IPv6, numeric IP forms, redirects, rebinding.
Immediate remediation
Section titled “Immediate remediation”Summary
Section titled “Summary”Self-check: (1) Why is startsWith('https://trusted.example.com') unsafe (hint: https://trusted.example.com@127.0.0.1/)? (2) Where does http://2130706433/ point? (3) Why is validating DNS and then letting the HTTP client resolve again still risky?