websec · level 2103

SSRF

Server-side request forgery — IMDS, internal scans, allowlists, and the TOCTOU trap.

250 XP

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)

  1. App accepts a user-supplied URL (image proxy, webhook target, file fetcher).
  2. App makes the HTTP request from the EC2 instance.
  3. Attacker supplies http://169.254.169.254/latest/meta-data/iam/security-credentials/SOME_ROLE.
  4. App returns the response — which contains short-lived AWS STS credentials for the instance role.
  5. Attacker uses those credentials to read S3 buckets the role had access to.
  6. ~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/passwd if 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.com and videos.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:

  1. attacker.example.com resolves to 1.2.3.4 (public IP) at check time → passes.
  2. 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:

  1. PUT request to obtain a session token (with X-aws-ec2-metadata-token-ttl-seconds).
  2. 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:. Refuse file:, 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
  • Out-of-band detection — gives a unique callback URL to plant in fields and watch for hits.

    service
  • AWS IMDSv2free tier

    Mandatory session-token IMDS — single-line setting that defeats most SSRF on AWS.

    service
  • SSRF exploitation toolkit — convenient for testing your own defenses.

    cli
  • Stripe's open-source SSRF-resistant HTTP proxy — sits between your app and the internet.

    service