SSRF
Server-side request forgery — IMDS, internal scans, allowlists, and the TOCTOU trap.
SSRF — Server-Side Request Forgery
Your app lets users fetch a URL — a webhook target, an avatar to mirror, a PDF to embed. The server happily makes the HTTP call on behalf of the user. The user supplies http://169.254.169.254/latest/meta-data/iam/security-credentials/MyAppRole. The server fetches it. The server returns the response.
The server has just leaked its own AWS credentials.
That's SSRF. The flaw is the unspoken assumption: "user-supplied URLs go to the public internet, places we'd want to go anyway." Reality: from inside your VPC, a URL can target your metadata service, your internal admin panels, your databases, your Redis cache — places no end user should ever reach.
The classic SSRF attack chain (Capital One, 2019)
- App accepts a user-supplied URL (image proxy, webhook target, file fetcher).
- App makes the HTTP request from the EC2 instance.
- Attacker supplies
http://169.254.169.254/latest/meta-data/iam/security-credentials/SOME_ROLE. - App returns the response — which contains short-lived AWS STS credentials for the instance role.
- Attacker uses those credentials to read S3 buckets the role had access to.
- ~100 million customers' data exfiltrated.
Capital One was fined $80 million for that one. The bug: a misconfigured WAF allowed the SSRF; the role had s3:ListBucket and s3:GetObject on every bucket in the account; IMDSv1 was in use. Three layers, all broken at once.
The targets
What can SSRF reach?
- Cloud metadata services: 169.254.169.254 (AWS, GCP, Azure, DigitalOcean, OpenStack — all use the same link-local IP). Returns instance-role credentials, user-data, instance identity. The crown jewels.
- Internal services: anything in your VPC. Internal admin consoles (Jenkins, Kibana, Consul), Redis (
redis://-aware libraries), Memcached, Elasticsearch. - Localhost:
http://127.0.0.1:6379— your local Redis is open. Most apps assume "anyone hitting localhost is us." That's wrong from inside the app. - External fingerprinting: the attacker can probe any IP and learn whether the service responds, even without reading the body. Useful for mapping internal infrastructure.
- File schemes (sometimes):
file:///etc/passwdif the HTTP library accepts file URLs. - Gopher / FTP / dict: not just HTTP — many libraries accept other schemes that can pivot into protocol-confusion (e.g. crafting a "fake HTTP" payload that's actually a Redis command).
Allowlist always wins
The single most important architectural choice: allowlist, not blocklist.
- A blocklist says "block 10.0.0.0/8, block 169.254.0.0/16, block ..." — and the attacker's job is to find one URL you didn't think to ban. They will find it. URL parsers disagree about edge cases; DNS rebinds; redirects fire; IPv6 mappings of IPv4 addresses bypass IPv4 filters; decimal IPs (
http://2130706433/) bypass dotted-quad filters. - An allowlist says "the only hosts I will fetch from are
images.example.comandvideos.example.com." Anything else fails closed.
If your business need is "let users supply arbitrary URLs," your business need is wrong; pre-process those URLs into an allowlisted CDN or proxy and fetch from there. Stripe's open-source Smokescreen is a reference implementation.
The TOCTOU trap
A common naive defense:
const url = new URL(userInput);
const ip = await dns.lookup(url.hostname);
if (isInternalIp(ip)) throw new Error("nope");
return fetch(userInput); // ← BUG
The check uses one DNS resolution; the fetch uses another. In between, the DNS record can flip:
attacker.example.comresolves to1.2.3.4(public IP) at check time → passes.- fetch() resolves it again, this time to
169.254.169.254→ succeeds.
This is DNS rebinding — TOCTOU at the DNS layer. The attacker controls the DNS server, returns short-TTL records, swaps responses between the two queries.
The fix is to pin the resolution: resolve once, validate, then make the actual HTTP request to the IP (with the original Host header preserved for vhost routing). Tools like Smokescreen do this for you.
IMDSv2 — the AWS lifesaver
Amazon shipped IMDSv2 in 2019 in response to Capital One. Where IMDSv1 accepted plain GET requests from anywhere on the instance, IMDSv2 requires:
- PUT request to obtain a session token (with
X-aws-ec2-metadata-token-ttl-seconds). - Subsequent GETs include
X-aws-ec2-metadata-token: <token>.
Most SSRF attacks can't make PUT requests (the app does GET-the-image-URL, not PUT). IMDSv2 single-handedly defeats most SSRF→IMDS attacks.
Make IMDSv2 mandatory on every EC2/ASG/EKS node:
resource "aws_instance" "app" {
metadata_options {
http_tokens = "required" # IMDSv2 mandatory
http_endpoint = "enabled"
http_put_response_hop_limit = 1 # blocks proxy/PID-1 forwarding
}
}
Set this once per AMI / launch template; review every account for non-compliant instances quarterly.
Other defenses
Stack these:
- Allowlist hostnames, not just IP ranges. "Anything in
images.example.com" beats "anything not in private space." - Validate the scheme.
http:,https:. Refusefile:,gopher:,dict:,ftp:,ldap:,jar:. - Resolve, validate, pin to defeat DNS rebinding. Use the resolved IP at fetch time with
Host:header set. - Refuse private IP ranges: 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1, fc00::/7, fe80::/10. Plus the carrier-grade NAT range 100.64.0.0/10. Plus IPv4-in-IPv6 mapping (::ffff:127.0.0.1). Plus localhost-named-as-IPv6 ::1.
- Limit followed redirects or refuse them entirely. A 30x can flip a public URL to an internal one.
- Cap response size and time. Blind SSRF to internal services often manifests as long delays or large bodies.
- Run the proxy in a separate IAM context with no instance role — even if the SSRF lands, the metadata service has nothing useful to leak.
- Network ACLs: outbound rules from the app subnet that only allow the egress destinations you actually need.
What a SSRF-resistant fetch looks like
import dns from "node:dns/promises";
import http from "node:http";
import { isIP } from "node:net";
const ALLOW_HOSTS = new Set(["images.example.com", "videos.example.com"]);
const PRIVATE = (ip: string) =>
/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|169\.254\.|0\.|::1$|^fc|^fe[89ab])/i.test(ip);
export async function safeFetch(rawUrl: string) {
const url = new URL(rawUrl);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("bad scheme");
if (!ALLOW_HOSTS.has(url.hostname)) throw new Error("host not allowed");
const { address } = await dns.lookup(url.hostname);
if (isIP(address) === 0) throw new Error("invalid resolution");
if (PRIVATE(address)) throw new Error("private IP refused");
// Make the request to the resolved IP, preserve the Host header.
return new Promise<http.IncomingMessage>((resolve, reject) => {
const req = http.request({
host: address,
port: url.port || 80,
path: url.pathname + url.search,
headers: { Host: url.host },
timeout: 5000,
}, resolve);
req.on("error", reject);
req.end();
});
}
That's the shape. It's not hard. It's worth the 50 lines.
What to never do
- Never use a blocklist when an allowlist is possible.
- Never trust
URL.parse()alone to be safe. RFC 3986 has more corner cases than your library handles. - Never run the SSRF-prone service with an instance role that has anything you'd be sad to leak.
- Never assume "but it's just a localhost call" is private. From inside the app, localhost is an attacker target.
- Never let users supply URLs without considering: who would be sad if the server fetched any URL of the user's choice?
In production
The unscoped "fetch any URL" feature is everywhere — webhooks, link previews, file uploads from URL, OAuth callback validation, image proxies. Treat each one as SSRF-prone by default. Audit them. Wire each through a hardened proxy. Set IMDSv2 mandatory. Sleep better.
Tools in the wild
4 tools- service
Out-of-band detection — gives a unique callback URL to plant in fields and watch for hits.
- serviceAWS IMDSv2free tier
Mandatory session-token IMDS — single-line setting that defeats most SSRF on AWS.
- clissrf-tool / SSRFmapfree tier
SSRF exploitation toolkit — convenient for testing your own defenses.
- serviceSmokescreen (Stripe)free tier
Stripe's open-source SSRF-resistant HTTP proxy — sits between your app and the internet.