SSRF - Cloud Metadata, DNS Rebinding, Redirects & Egress Allowlists
SSRF is your server fetching a URL the attacker chose. The request leaves from inside your network, often with the host's cloud role. Metadata at 169.254.169.254 is the famous target. Pin the resolved IP, re-check every redirect, allowlist egress, require IMDSv2 with hop limit 1, and keep that role small.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Question ladder
L1
Why is SSRF so dangerous in the cloud specifically?
Answer
The metadata service sits on a link-local address reachable from every instance and hands out credentials for the instance's IAM role. SSRF turns "fetch a URL" into "become this server in the cloud account".
L2
What is DNS rebinding and how do you defend against it?
Answer
The attacker's DNS returns a public IP to your validator and a private IP to the HTTP client moments later (low TTL). Resolve once, validate, and connect to that pinned IP; or route all fetches through an egress proxy that does the check at connect time.
L3
Design a safe webhook delivery system.
Answer
Validate URLs at registration and again at send time, pin IPs, block private ranges, disallow redirects, deliver from an isolated worker pool behind an egress proxy, sign payloads (HMAC) so receivers can verify them, apply timeouts and size caps, and never return response bodies to the tenant beyond status codes.
L4
What's blind SSRF?
Answer
The attacker doesn't see the response but can still trigger internal side effects or infer open ports from timing. It's still exploitable, especially against internal APIs with GET side effects.
L5
How does IMDSv2 mitigate SSRF?
Answer
It needs a `PUT` to get a session token and a header on every metadata request. Most SSRF bugs only allow a GET with no custom headers, so they can't get credentials. Enforce `HttpTokens=required` and a hop limit of 1.
L6
A PDF renderer (headless Chrome) turns user HTML into PDFs. What's the SSRF risk?
Answer
The HTML can reference `<img src="http://169.254.169.254/...">` or `file://` URLs, and the browser fetches them server-side. Run the renderer in a sandbox with no network or only an egress-proxied network, disable `file://`, and use a no-permission IAM role.
Failure modes
String check, then requests.get
The validator saw a public address. The client resolved again and got metadata.
Follow redirects by default
An allowed host 302s to 169.254.169.254. The hop is a new request.
Return the body to the user
Blind SSRF becomes a full read, and error text becomes a port scan.
Fetcher uses the app role
One GET to metadata is read access to every bucket that role can see.
Misconceptions
Blocking localhost and the metadata hostname is enough.
Decimal IPs, IPv6-mapped addresses, DNS, and redirects never contain that string.
IMDSv2 is optional hardening.
Most SSRF bugs are a GET with no custom header. A required token and hop limit 1 make that GET fail.
Blind SSRF is harmless because the attacker sees nothing.
Internal GETs with side effects, and timing differences, are still a bug.
Interviewer traps
Designing a webhook sender that validates the URL only when the user saves it.
Validate again at send time. DNS and redirects change. Pin the IP on the dial.
Putting a headless browser on the app network so it can render HTML.
The HTML can reference metadata and file URLs. Sandbox the renderer with no route to metadata and no file scheme.
Design scenario
Same prompt for every reader.
Requirements
Webhooks may be any public https host. They must not reach metadata, RFC1918, or localhost. Redirects must not become a tunnel. Avatar fetches have the same rule.
Traffic / scale
Tens of webhook deliveries per second, with retries.
Latency
One DNS lookup per delivery, reused only for the pinned address of that attempt.
Consistency
The URL checked at registration is checked again at send time.
Availability
A denied destination is a failed delivery for that endpoint, not a worker crash.
Failure assumptions
- DNS answers public, then link-local.
- A 302 points at metadata.
Constraints
- The worker role cannot read application buckets.
- IMDSv2 hop limit is 1.
Prompt
Customers register a webhook URL. You POST signed events to it from a worker. A separate feature imports an avatar from a URL.
API
What do you store at registration, and what do you return to the tenant after a delivery?
Data
Which IP ranges are denied, and how is the pinned address passed to the client?
Architecture
Is the worker on a subnet with a default route to the internal admin network?
Check the connection, not the string
Prefer
Resolve once, pin the IP, re-check redirects
The socket opens to an address you already classified as public. A second DNS answer cannot move it.
- Decimal, IPv6-mapped, and userinfo forms die at parse time.
- An allowlist of hosts is stronger when the set is known.
- IMDSv2 and a tiny IAM role limit metadata and buckets.
Alternative
Block the string 169.254.169.254
The dangerous destination has many spellings, and DNS can change its mind.
- 2130706433 is 127.0.0.1.
- A 302 from an allowed host lands on metadata.
- Checking DNS and then calling the HTTP client re-resolves.
A URL the server is about to fetch
Every hop, including a redirect, goes through the same gate.
- 1
Parse the URL
Allow http and https only. Drop file, gopher, and dict. - 2
Resolve once
Reject loopback, private, link-local, and multicast, including IPv4-mapped IPv6. - 3
Pin the connect
Dial the validated IP. Send the original Host and SNI. - 4
Re-check redirects
A 302 is a new URL. Disable redirects or run the same gate per hop. - 5
Shrink the badge
Egress proxy, IMDSv2 hop limit 1, and a role that cannot read every bucket.
Overview
Server-side request forgery (SSRF) happens when your server fetches a URL the user controls: webhooks, link previews, "import from URL", PDF/image renderers, OAuth/OIDC discovery, XML external entities. The request leaves from inside your network, with your server's network position and often its cloud credentials. The classic target is the cloud metadata endpoint at 169.254.169.254, which hands out temporary IAM credentials. That's how the 2019 Capital One breach worked: a misconfigured WAF was tricked into fetching metadata credentials, which then read S3 buckets. SSRF has been its own OWASP Top 10 category since 2021.
The fix is layered: validate and pin the destination IP, re-check every redirect, send fetches through an egress proxy with an allowlist, lock down metadata (IMDSv2, hop limit 1), and give the fetching service minimal IAM permissions.
Decisions
- 1
1. User submits URL: webhook, avatar import, link preview
- next2. Parse URL, allow only http and https
- 2
2. Parse URL, allow only http and https
- next3. Resolve DNS once, reject private, loopback, link-local IPs
- 3
3. Resolve DNS once, reject private, loopback, link-local IPs
- next4. Connect to the pinned IP through an egress proxy
- 4
4. Connect to the pinned IP through an egress proxy
- next5. Redirect returned?
- ?
5. Redirect returned?
- yes2. Parse URL, allow only http and https
- no6. Return response with size and time limits
- 6
6. Return response with size and time limits
Lesson map
SSRF - Cloud Metadata, DNS Rebinding, Redirects & Egress Allowlists
SSRF is your server fetching a URL the attacker chose. The request leaves from inside your network, often with the host's cloud role. Metadata at 169.254.169.254 is the famous target. Pin the resolved IP, re-check every redirect, allowlist egress, require IMDSv2 with hop limit 1, and keep that role small.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB a["1. User submits URL: webhook, avatar import, link preview"] b["2. Parse URL, allow only http and https"] c["3. Resolve DNS once, reject private, loopback, link-local IPs"] d["4. Connect to the pinned IP through an egress proxy"] a -->|1. User submits URL: webhook, avatar import, link preview to 2. Parse URL, allow only http and https| b b -->|2. Parse URL, allow only http and https to 3. Resolve DNS once, reject private, loopback, link-local IPs| c c -->|3. Resolve DNS once, reject private, loopback, link-local IPs to 4. Connect to the pinned IP through an egress proxy| d
Which subnet that fetcher can route to is Private networking. What a stolen instance credential can decrypt or read is Secrets and KMS.
What SSRF can reach
| Target | Example URL | Impact |
|---|---|---|
| Cloud metadata | http://169.254.169.254/latest/meta-data/iam/security-credentials/ | Temporary IAM credentials, full cloud account compromise |
| Internal admin panels | http://10.0.3.7:8080/admin | Unauthenticated internal APIs |
| Localhost services | http://127.0.0.1:6379/ (Redis), :2375 (Docker) | Data theft, sometimes RCE |
| Kubernetes API / kubelet | https://kubernetes.default.svc/ | Cluster takeover if the service account is privileged |
| Other schemes | file:///etc/passwd, gopher://, dict:// | Local file reads, raw protocol smuggling |
Validation that survives bypasses
Naive checks on the URL string fail against decimal IPs (2130706433 is 127.0.0.1), IPv6-mapped IPv4 ([::ffff:127.0.0.1]), userinfo tricks (http://good.com@10.0.0.1), DNS names that resolve to private IPs, and DNS rebinding, where a name resolves to a public IP during your check and a private IP when the HTTP client resolves it again.
# SSRF guard: parse, resolve ONCE, reject private/link-local/loopback IPs,
# then connect to that exact IP (pinning defeats DNS rebinding).
import ipaddress
from urllib.parse import urlsplit
# Fake DNS so the demo is deterministic and offline. "rebind.evil" answers
# a public IP on the first lookup and the metadata IP on the second.
_rebind_calls = {"n": 0}
def fake_resolve(host):
table = {"api.partner.com": ["93.184.215.14"], "internal.corp": ["10.0.3.7"],
"metadata.google.internal": ["169.254.169.254"]}
if host == "rebind.evil":
_rebind_calls["n"] += 1
return ["93.184.215.14"] if _rebind_calls["n"] == 1 else ["169.254.169.254"]
try:
# Browsers/curl also accept decimal and IPv6 forms of an IP literal.
return [str(ipaddress.ip_address(int(host) if host.isdigit() else host))]
except ValueError:
return table.get(host, [])
def is_public(ip):
a = ipaddress.ip_address(ip)
if a.version == 6 and a.ipv4_mapped: # ::ffff:127.0.0.1 hides IPv4
a = a.ipv4_mapped
return a.is_global and not a.is_multicast
def check(url):
u = urlsplit(url)
if u.scheme not in ("http", "https"):
return "DENY scheme " + u.scheme
ips = fake_resolve(u.hostname or "")
if not ips or not all(is_public(ip) for ip in ips):
return f"DENY {u.hostname} -> {ips}"
return f"ALLOW connect to pinned {ips[0]} (Host: {u.hostname})"
for url in ["https://api.partner.com/hook", "http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin", "http://[::ffff:127.0.0.1]/", "http://user@internal.corp/",
"file:///etc/passwd", "http://rebind.evil/"]:
print(f"{url:45} {check(url)}")
# Naive validator resolves twice (check, then the HTTP client resolves again):
print("naive 2nd lookup for rebind.evil ->", fake_resolve("rebind.evil"), "(metadata!)")Output:
https://api.partner.com/hook ALLOW connect to pinned 93.184.215.14 (Host: api.partner.com)
http://169.254.169.254/latest/meta-data/ DENY 169.254.169.254 -> ['169.254.169.254']
http://2130706433/admin DENY 2130706433 -> ['127.0.0.1']
http://[::ffff:127.0.0.1]/ DENY ::ffff:127.0.0.1 -> ['::ffff:127.0.0.1']
http://user@internal.corp/ DENY internal.corp -> ['10.0.3.7']
file:///etc/passwd DENY scheme file
http://rebind.evil/ ALLOW connect to pinned 93.184.215.14 (Host: rebind.evil)
naive 2nd lookup for rebind.evil -> ['169.254.169.254'] (metadata!)Pinning means the HTTP client connects to the exact IP you validated (sending the original Host header and SNI), so a second DNS answer can't redirect it.
Redirects are a second entry point
A URL on an allowed host can answer 302 Location: http://169.254.169.254/.... Either disable redirects or validate every hop with the same rules.
// SSRF also hides in REDIRECTS: a public URL can 302 to an internal one.
// Re-validate every hop (or disable redirects) and cap the hop count.
type Hop = { url: string; status: number; location?: string };
// Simulated upstream responses (offline, deterministic).
const web: Record<string, Hop> = {
"https://img.partner.com/a.png": { url: "https://img.partner.com/a.png", status: 200 },
"https://short.ly/x": { url: "https://short.ly/x", status: 302, location: "https://169.254.169.254/latest/api/token" },
};
const ALLOWED_HOSTS = new Set(["img.partner.com", "short.ly"]); // egress allowlist
const PRIVATE = [/^10\./, /^127\./, /^169\.254\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./];
function hopAllowed(u: URL): string | null {
if (u.protocol !== "https:") return `scheme ${u.protocol} not allowed`;
if (PRIVATE.some((r) => r.test(u.hostname))) return `private address ${u.hostname}`;
if (!ALLOWED_HOSTS.has(u.hostname)) return `host ${u.hostname} not on egress allowlist`;
return null;
}
function fetchSafely(start: string, maxHops = 3): string {
let current = start;
for (let i = 0; i <= maxHops; i++) {
const reason = hopAllowed(new URL(current));
if (reason) return `BLOCKED at hop ${i}: ${reason}`;
const res = web[current];
if (!res) return `BLOCKED at hop ${i}: unknown upstream`;
if (res.status !== 302 || !res.location) return `OK ${res.status} after ${i} redirect(s)`;
current = new URL(res.location, current).toString();
}
return "BLOCKED: too many redirects";
}
for (const u of Object.keys(web)) console.log(`${u.padEnd(32)} -> ${fetchSafely(u)}`);Output:
https://img.partner.com/a.png -> OK 200 after 0 redirect(s)
https://short.ly/x -> BLOCKED at hop 1: private address 169.254.169.254Defenses compared
| Layer | What it stops | Weakness | Verdict |
|---|---|---|---|
Hostname blocklist (localhost, 169.254.169.254) | Only the obvious strings | Encodings, DNS, IPv6, redirects | Not sufficient |
| Resolve-and-check IP ranges | Private and link-local targets | DNS rebinding unless you pin | Required |
| Destination allowlist (known partner hosts) | Everything else | Not possible for "any URL" features | Best when the domain set is known |
| Egress proxy (Smokescreen, Squid with ACLs) | Central place for IP rules, logging | Another service to run | Strong default for multi-service orgs |
| IMDSv2 required, hop limit 1 | Metadata theft through simple GET SSRF | Only covers metadata | Always on in AWS |
| Network segmentation (fetcher in its own subnet, no route to internal) | Lateral movement | Infra work | Strong for high-risk fetchers |
| Least-privilege IAM role | Blast radius | Doesn't stop the request | Always |
AWS IMDSv2 requires a session token obtained by a PUT with a custom header, which most SSRF primitives (a server doing a GET) can't produce. A hop limit of 1 stops containers behind an extra network hop from reaching it. GCP requires the Metadata-Flavor: Google header, and Azure requires Metadata: true, for the same reason.
What happens if you choose differently
- Validate the string, then let
requests.get(url)re-resolve: DNS rebinding wins. - Follow redirects by default: an open redirect on any allowed site becomes a path to internal hosts.
- Return the fetched body or detailed errors to the user: that turns blind SSRF into full-read SSRF and port scanning (timing and error differences reveal open ports).
- Run the fetcher with the app's broad IAM role: one SSRF becomes read access to every bucket.
List every place the server takes a URL from a user: webhooks, avatars, PDF HTML, OIDC discovery. For each, write whether redirects are followed and whether the HTTP client resolves DNS itself.
Interview Q&A
Why is SSRF so dangerous in the cloud specifically?
Answer
The metadata service sits on a link-local address reachable from every instance and hands out credentials for the instance's IAM role. SSRF turns "fetch a URL" into "become this server in the cloud account".
What is DNS rebinding and how do you defend against it?
Answer
The attacker's DNS returns a public IP to your validator and a private IP to the HTTP client moments later (low TTL). Resolve once, validate, and connect to that pinned IP; or route all fetches through an egress proxy that does the check at connect time.
Design a safe webhook delivery system.
Answer
Validate URLs at registration and again at send time, pin IPs, block private ranges, disallow redirects, deliver from an isolated worker pool behind an egress proxy, sign payloads (HMAC) so receivers can verify them, apply timeouts and size caps, and never return response bodies to the tenant beyond status codes.
What's blind SSRF?
Answer
The attacker doesn't see the response but can still trigger internal side effects or infer open ports from timing. It's still exploitable, especially against internal APIs with GET side effects.
How does IMDSv2 mitigate SSRF?
Answer
It needs a PUT to get a session token and a header on every metadata request. Most SSRF bugs only allow a GET with no custom headers, so they can't get credentials. Enforce HttpTokens=required and a hop limit of 1.
A PDF renderer (headless Chrome) turns user HTML into PDFs. What's the SSRF risk?
Answer
The HTML can reference <img src="http://169.254.169.254/..."> or file:// URLs, and the browser fetches them server-side. Run the renderer in a sandbox with no network or only an egress-proxied network, disable file://, and use a no-permission IAM role.