Skip to content
Knowledge Hub

SSRF: when an API fetches a client-supplied URL Intermediate

Estimated reading time: 10 min read

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.

An untrusted URL turns the API into a client calling into the internal network
An untrusted URL turns the API into a client calling into the internal networkStep 1/4: The client sends a URL for the API to fetch an image from; the URL is untrusted data.ClientFetch APIInternalURL
Client → Fetch APIURL
Step 1/4

The client sends a URL for the API to fetch an image from; the URL is untrusted data.

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.

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:

Terminal window
npx --prefix examples ts-node examples/src/ssrf/local-ssrf-demo.ts

The 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.

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 BLOCKED
http://2130706433/ -> 127.0.0.1 BLOCKED
http://0x7f.1/ -> 127.0.0.1 BLOCKED
http://0177.0.0.1/ -> 127.0.0.1 BLOCKED
http://[::ffff:127.0.0.1]/ -> ::ffff:7f00:1 BLOCKED
http://[::1]/ -> ::1 BLOCKED
http://169.254.169.254/ -> 169.254.169.254 BLOCKED
http://8.8.8.8/ -> 8.8.8.8 allowed

Lesson: 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.

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.

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.
DNS rebinding: check one IP, connect to another
DNS rebinding: check one IP, connect to anotherStep 1/6: The attacker submits a URL whose hostname they control, including its DNS and TTL.timesubmits URLevil.exampleAttackercontrols DNSURL-fetching APIvalidator + clientDNS evil.exampleTTL = 0Internal service127.0.0.1
Step 1/6

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-secret
redirect: manual -> 302 http://127.0.0.1:64768/admin

Next, 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.1
pinned (resolve once): connect to 203.0.113.9 (validated IP) -> timeout
resolve 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.)

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.

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 NetworkPolicy that 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.
  1. List every place where outbound requests are built from input: avatar importers, link previews, PDF/screenshot renderers, webhook senders, image proxies.
  2. If an allowlist is possible, use an exact allowlist; build the scheme, port and path yourself.
  3. Only allow http/https and the ports you need; reject URLs with userinfo.
  4. Resolve once, check every A/AAAA (including IPv4-mapped), connect to the exact validated IP.
  5. Redirects: disable automatic following; validate every hop again, with at most a few hops.
  6. Set timeouts, body size limits and concurrency limits for fetch workers.
  7. Egress policy at the network layer; IMDSv2 required; minimal IAM roles.
  8. Log the hostname, connected IP, port and hop count; alert on requests to private/link-local ranges.
  9. Test in an isolated environment with fake servers: loopback, private, IPv6, numeric IP forms, redirects, rebinding.

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?