L35 · Server-side request forgery (SSRF)
Module 5: Web Application Security · ~20 min · Lesson 35 of 71
🎯 What you'll learn
- What SSRF is: an attacker choosing where your server sends a request, from inside your network
- What an attacker can reach that way: internal APIs, admin ports, and the cloud metadata endpoint
- How IMDSv2 and its hop limit protect EC2 credentials, and what that means for pods on EKS
- The layered fix: accept IDs instead of URLs, allow-lists, resolved-IP checks, egress rules, small roles
🔥 Why this matters
Partners send a bank's statements service {"documentUrl": "https://..."} and it downloads the PDF.
A pen tester sends http://169.254.169.254/latest/meta-data/iam/security-credentials/ instead. The
error message names the worker node's IAM role, and the next request returns its temporary AWS keys.
That pattern sits behind the 2019 Capital One breach of about 106 million people's records (L47 has
the full story). SSRF was OWASP's A10 in 2021; the 2025 list folds it into A01 (L29).
1. Your server becomes the attacker's browser
Picture a building where visitors can't pass reception, but the receptionist will fetch any folder you name, including one from the locked records room. The receptionist is a confused deputy: someone with access who uses it on behalf of someone without it.
Server-side request forgery (SSRF) is a flaw where an attacker controls, fully or partly, the destination of a request your server makes. The request carries your server's network position and trust, so firewalls and "internal only" rules don't stop it.
attacker statements-svc internal admin API
(internet) (public, via Kong) 10.20.3.7:8001
│ │ (no auth: "inside")
│ 1. POST /import │ │
│ {"documentUrl": │ │
│ "http://10.20.3.7: │ │
│ 8001/"} │ │
│───────────────────────▶│ │
│ │ 2. GET http:// │
│ │ 10.20.3.7:8001/ │
│ │────────────────────────▶│
│ │ 3. internal data │
│ │◀────────────────────────│
│ 4. data relayed in │ │
│ the response │ │
│◀───────────────────────│ │
│ │
└──── ✗ direct path blocked by firewall / VPC ─────┘
URLs reach your server in more places than an "import" field: webhook URLs, link previews, PDF
generators rendering HTML (an <img src> is a fetch), XML external entities (XXE), and the JWT
jku header (L25).
There are two flavors. In basic SSRF the attacker sees the response, as in step 4. In blind
SSRF they don't, but can still map your network from timing and errors, or hit endpoints that
change state on a GET.
2. What's on the other side
From inside your VPC, a server can reach things nobody meant to expose:
| Target | Example | Why it hurts |
|---|---|---|
| Cloud metadata | http://169.254.169.254/ |
Temporary credentials for the instance's IAM role |
| Loopback admin ports | http://127.0.0.1:8001/ (Kong Admin API's default port) |
The Admin API can add routes and plugins; Kong's docs say to keep it off untrusted networks |
| Framework endpoints | /actuator/heapdump on a Spring Boot service |
A heap dump holds secrets and tokens in memory (L38) |
| "Trusted" internal APIs | http://accounts-svc.payments.svc.cluster.local/ |
Services that skip auth because "only the cluster can call us" |
| Other schemes | file:///var/run/secrets/..., gopher:// |
Some HTTP clients (libcurl, older libraries) read local files or send raw bytes to non-HTTP services |
3. The metadata endpoint: IMDSv1 vs IMDSv2
Every EC2 instance, including each EKS worker node, can reach the Instance Metadata Service
(IMDS) at the link-local address 169.254.169.254. It serves instance data and, if the instance
has an IAM role (its instance profile), temporary credentials for that role.
IMDSv1 is a plain unauthenticated GET:
GET /latest/meta-data/iam/security-credentials/
→ eks-node-role-example
GET /latest/meta-data/iam/security-credentials/eks-node-role-example
→ {"AccessKeyId": "ASIAEXAMPLE7Q2", "SecretAccessKey": "...",
"Token": "...", "Expiration": "2026-11-07T18:12:09Z"}
Any SSRF that can send a GET and read the reply gets keys that work from anywhere until they
expire.
IMDSv2 (November 2019) adds a session token. You first PUT /latest/api/token with the header
X-aws-ec2-metadata-token-ttl-seconds: 21600, then send the returned token in an
X-aws-ec2-metadata-token header on every GET. That defeats most SSRF in four ways:
- It needs a
PUT. Most SSRF bugs can only make the server send aGET. - It needs custom headers. Most SSRF bugs can't set request headers.
- It refuses proxied token requests. A
PUTcarryingX-Forwarded-Foris rejected, so an open reverse proxy or misconfigured WAF can't relay it. - The hop limit. The token reply's IP TTL is the instance's
HttpPutResponseHopLimit(default 1, range 1 to 64). Each routing hop subtracts one; at zero the packet is dropped.
Point 4 matters for containers. An EKS pod has its own network namespace, one routed hop away from the node:
┌──────────────────── EC2 worker node ─────────────────────┐
│ host network namespace pod network namespace │
│ ┌───────────────────┐ veth ┌────────────────────┐ │
│ │ kubelet, aws-node │ ◀─────▶ │ statements-svc pod │ │
│ │ hostNetwork pods │ +1 hop │ │ │
│ └─────────┬─────────┘ └────────────────────┘ │
└────────────┼─────────────────────────────────────────────┘
│ PUT /latest/api/token
▼
IMDS 169.254.169.254
token reply leaves with TTL = hop limit
hop limit 1 → reaches the host, dropped before the pod
hop limit 2 → reaches the pod too
So HttpTokens=required plus hop limit 1 means ordinary pods can't get the node's credentials,
while the node (and hostNetwork pods) still can. Two caveats. If IMDSv1 is still allowed
(HttpTokens=optional), the hop limit protects nothing: v1 has no token to drop. And defaults
vary: in 2020 AWS set EKS managed node groups to hop limit 2 so pods could use IMDSv2, and newer
AMIs and templates differ, so check the node group's launch template.
Pods that need AWS access should use their own role through IRSA or EKS Pod Identity (L50), which don't use the node's IMDS. Some add-ons read the region from IMDS; pass it as config first.
4. Defenses, in layers
Each layer covers gaps in the one above it:
caller input
│
▼ 1. Don't accept URLs: accept a partner ID or document ID
▼ 2. Allow-list exact scheme, host and port from config
▼ 3. If you truly need arbitrary URLs: resolve DNS, reject
│ private/loopback/link-local IPs, connect to the IP you
│ checked, no redirects (Going deeper 1)
▼ 4. Network: egress rules so the pod can't reach 169.254/16
│ or admin ports, even if the code is wrong (L12)
▼ 5. Cloud: IMDSv2 required, hop limit 1, small pod roles
(L46), GuardDuty alerts on credential misuse (L52)
Prefer allow-lists to blocklists: a blocklist must predict every spelling of
169.254.169.254 (the Try it shows how that fails). Keep upstream details out of error messages,
so a blind SSRF stays blind. On EKS, NetworkPolicy only works if something enforces it (the VPC
CNI's network policy feature, or Calico).
🧪 Worked example: a document import, vulnerable vs fixed
Before. The URL goes straight from the request into the HTTP client:
@PostMapping("/v1/statements/import")
ImportResult importDoc(@RequestBody ImportRequest req) {
byte[] pdf = restClient.get()
.uri(req.documentUrl()) // caller picks the host
.retrieve()
.body(byte[].class);
return importer.store(pdf); // error echoes the body
}
What the tester sees (made-up values):
POST /v1/statements/import HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"partnerId": "P-1042", "documentUrl":
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"}
HTTP/1.1 422 Unprocessable Entity
{"error": "Not a PDF: 'eks-node-role-example'"}
The "helpful" error turned blind SSRF into basic SSRF, and the node allowed IMDSv1.
After. The caller names a partner and document; the URL comes from config:
// partners.yml (config, not input):
// P-1042: https://docs.partner-a.example
private static final Pattern DOC_ID =
Pattern.compile("[A-Z0-9-]{6,40}");
@PostMapping("/v1/statements/import")
ImportResult importDoc(@RequestBody ImportRequest req) {
Partner p = partners.require(req.partnerId()); // 404 if unknown
if (!DOC_ID.matcher(req.documentId()).matches())
throw new BadRequestException("invalid documentId");
URI uri = UriComponentsBuilder.fromUri(p.baseUri())
.pathSegment("documents", req.documentId())
.build().toUri();
byte[] pdf = partnerClient.get().uri(uri) // redirects off,
.retrieve().body(byte[].class); // 5 s timeout,
return importer.store(pdf); // 10 MB cap
}
Then the node layer (for managed node groups, set the same MetadataOptions in the launch
template, since nodes get replaced):
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123example \
--http-tokens required \
--http-put-response-hop-limit 1
Even if a future change brings back a URL parameter, the pod can't get a token, and its own IRSA role reaches only the one bucket it needs.
🛠 Try it (optional, ~5 min)
Compare a blocklist with a resolve-then-check filter. It runs locally and sends no requests. Save
as ssrf_check.py:
import ipaddress, socket
from urllib.parse import urlsplit
BLOCKED = {"localhost", "127.0.0.1", "169.254.169.254"}
def naive_ok(url):
return urlsplit(url).hostname not in BLOCKED
def resolved_ok(url):
host = urlsplit(url).hostname
try:
infos = socket.getaddrinfo(host, 80)
except socket.gaierror:
return False
for *_, addr in infos:
ip = ipaddress.ip_address(addr[0].split("%")[0])
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if not ip.is_global:
return False
return True
TESTS = [
"http://169.254.169.254/latest/meta-data/",
"http://2852039166/latest/meta-data/",
"http://0xA9FEA9FE/latest/meta-data/",
"http://[::ffff:169.254.169.254]/",
"http://127.1:8001/routes",
"http://[::1]:8001/routes",
"http://10.20.3.7:8001/routes",
]
for url in TESTS:
print(f"{url:42} naive={'PASS' if naive_ok(url) else 'block':5}"
f" resolved={'PASS' if resolved_ok(url) else 'block'}")
Real output from python3 -I ssrf_check.py on macOS:
http://169.254.169.254/latest/meta-data/ naive=block resolved=block
http://2852039166/latest/meta-data/ naive=PASS resolved=block
http://0xA9FEA9FE/latest/meta-data/ naive=PASS resolved=block
http://[::ffff:169.254.169.254]/ naive=PASS resolved=block
http://127.1:8001/routes naive=PASS resolved=block
http://[::1]:8001/routes naive=PASS resolved=block
http://10.20.3.7:8001/routes naive=PASS resolved=block
Decimal, hex, short and IPv6 spellings all slip past the blocklist, because the OS treats them as
the same address. Then delete the two ipv4_mapped lines and rerun: whether the mapped address is
still blocked depends on your Python version, which is why you normalize explicitly. The resolver
check still has a hole; Going deeper 1 shows it.
🧠 Key takeaways
- SSRF lets an attacker send requests from your server's network position, with its trust.
- The prize is usually
169.254.169.254: IMDSv1 hands out role credentials to anyGET. - Require IMDSv2 and set hop limit 1 on EKS nodes; give pods their own roles through IRSA or Pod Identity.
- Best fix: don't accept URLs at all. Next best: an allow-list from config. Blocklists lose.
- Back the code with egress rules and generic errors, so one bug isn't game over.
📖 Words to know
| Term | Meaning |
|---|---|
| SSRF | Server-side request forgery: the attacker controls where your server sends a request |
| Blind SSRF | SSRF where the attacker can't see the response, only side effects or timing |
| Confused deputy | A trusted component tricked into using its access on someone else's behalf |
| IMDS | Instance Metadata Service at 169.254.169.254: instance data and role credentials |
| IMDSv2 | Token-based IMDS: PUT for a session token, then GET with the token header |
| Hop limit | HttpPutResponseHopLimit: the IP TTL on IMDSv2 token replies, 1 by default |
| Allow-list | Only explicitly listed destinations are permitted; everything else is refused |
⏭ Next time
L36: how turning bytes back into objects can run an attacker's code, and how one log line,
${jndi:ldap://...}, became the internet's worst week of December 2021.
SSRF filter bypasses: redirects, DNS rebinding and odd IP formats
The Try it showed that the OS accepts many spellings of one address. A filter that resolves the name and checks the IP beats those. But two classic tricks still get past it, because the check and the request are two separate events.
Trick 1: the redirect
The filter checks https://files.attacker.example/report.pdf, which resolves to a public IP. It
passes. The attacker's server answers:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/
Most HTTP clients follow redirects by default (Python requests, curl with -L, Java's
HttpClient only if you set followRedirects(NORMAL); defaults differ per library, so check
yours). The second request is never checked. Fix: turn redirects off for user-supplied URLs, or
follow them yourself, running the full check on every hop.
Trick 2: DNS rebinding
The attacker controls the DNS for rebind.attacker.example and sets a TTL of 0 seconds:
your service attacker's DNS server
│ 1. resolve rebind.attacker.example (filter)
│ ─────────────────────────────────▶ │
│ ◀── 203.0.113.10 (public, passes) ─│
│ │
│ 2. resolve again (HTTP client) │
│ ─────────────────────────────────▶ │
│ ◀── 169.254.169.254 (TTL 0) ───────│
│
│ 3. GET http://169.254.169.254/... ──▶ IMDS
This is a time-of-check to time-of-use (TOCTOU) bug: the filter and the client each resolve the
name, and nothing forces the two answers to match. Fix: resolve once, check that IP, then connect
to that IP, sending the original host name in the Host header and TLS SNI. Most HTTP libraries
can do this with a custom DNS resolver hook (OkHttp's Dns interface, Apache HttpClient's
DnsResolver, a custom adapter in Python).
Worked example: a fetcher that pins the IP
Here is the shape in Python, using the standard library so every step is visible:
import http.client, ipaddress, socket, ssl
from urllib.parse import urlsplit
def safe_ip(host, port):
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
ips = {ipaddress.ip_address(i[4][0].split("%")[0])
for i in infos}
for ip in ips:
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if not ip.is_global:
raise ValueError(f"blocked address {ip}")
return str(sorted(ips, key=str)[0]) # checked IP, used below
def fetch(url, timeout=5):
u = urlsplit(url)
if u.scheme != "https" or u.username or u.password:
raise ValueError("https only, no credentials in URL")
ip = safe_ip(u.hostname, u.port or 443)
ctx = ssl.create_default_context()
raw = socket.create_connection((ip, u.port or 443), timeout)
tls = ctx.wrap_socket(raw, server_hostname=u.hostname)
conn = http.client.HTTPSConnection(u.hostname, timeout=timeout)
conn.sock = tls # connect to checked IP
conn.request("GET", u.path or "/") # Host header = hostname
resp = conn.getresponse()
if 300 <= resp.status < 400:
raise ValueError("redirects not followed")
return resp.read(10 * 1024 * 1024) # size cap
What each part buys you: https only removes file://, gopher:// and plain-HTTP metadata;
checking every resolved address stops a name that returns one public and one private IP;
connecting to the checked IP kills rebinding; TLS verification against the real host name still
works because server_hostname is set; redirects are refused; the size cap and timeout stop a slow
or huge response from tying up your threads. In production, prefer a maintained library or an
egress proxy (Going deeper 2) over hand-rolled code like this. It's here to show the moving parts.
Other spellings worth knowing
| Input | Why it's dangerous |
|---|---|
http://169.254.169.254.nip.io/ |
Wildcard DNS services return the IP embedded in the name |
http://user@169.254.169.254/ |
Naive string checks look at the wrong part of the URL |
http://[fd00:ec2::254]/ |
IMDS over IPv6 on Nitro instances, if IPv6 endpoint is enabled |
http://0.0.0.0:8001/ |
Often reaches services bound to localhost |
ipaddress.is_global handles most of these once the name is resolved, which is why resolving
first and judging the IP, not the string, matters.
🧠 Extra takeaways
- Validate the IP you'll actually connect to, not the string the caller sent.
- Redirects and DNS rebinding both exploit the gap between check and use; close it by pinning the IP.
- Turn off automatic redirects for user-supplied URLs, and cap time and size.
- Hand-rolled validators are fragile; prefer a vetted library or a dedicated egress proxy.
Webhooks and URL fetchers: designing a feature that calls customer URLs
Sometimes accepting URLs is the product. A payments platform lets each partner register a webhook
URL to receive payment.settled events. You can't allow-list hosts you don't know yet. The answer
is to design the feature so that even a successful SSRF reaches nothing interesting.
Put the fetcher somewhere boring
The core idea, used by companies that run webhooks at scale (Stripe open-sourced its egress proxy, Smokescreen, for this reason), is to route every outbound call to a customer URL through a dedicated egress proxy that refuses internal destinations, and to block those workloads from going anywhere else:
┌──────────── EKS: namespace webhooks ────────────┐
│ │
│ webhook-sender pod │
│ • no IAM role (no IRSA, no Pod Identity) │
│ • NetworkPolicy egress: ONLY the proxy │
│ │ │
│ │ 1. CONNECT hooks.partner-b.example:443 │
│ ▼ │
│ egress proxy (Smokescreen-style) │
│ • resolves DNS itself, blocks private, │
│ loopback, link-local, metadata ranges │
│ • logs every destination with the partner ID │
└────────┼────────────────────────────────────────┘
│ 2. public internet only, via NAT
▼
hooks.partner-b.example
Now three independent things must fail for an attacker to reach IMDS or Kong's Admin API: the proxy's IP check, the NetworkPolicy, and IMDSv2 with hop limit 1. And even then, the sender pod has no role to steal and no internal service to call.
Rules for the feature itself
- Validate at registration and at send time. DNS can change after a partner registers.
- HTTPS only, standard ports only (443, maybe 8443). Port scanning through your sender gets boring fast.
- Don't reflect responses. Store the status code and latency for the partner's dashboard, not the response body. That keeps a blind SSRF blind.
- Sign what you send with an HMAC (L17), so partners can verify you, and a forged request can't impersonate you either.
- Time-box and rate-limit per partner: 5 seconds, a few retries with back-off, and a circuit breaker for endpoints that keep failing.
Worked example: spotting blind SSRF in your logs
Blind SSRF often shows up first as odd destinations in the proxy log, before anything is stolen. A made-up log extract:
ts=2026-11-07T09:14:02Z partner=P-2207 dst=hooks.partner-b.example
ip=203.0.113.44:443 status=200 ms=212
ts=2026-11-07T09:14:05Z partner=P-3391 dst=a1.rebind.example
ip=10.20.3.7:443 action=DENY reason=private_ip
ts=2026-11-07T09:14:05Z partner=P-3391 dst=a2.rebind.example
ip=10.20.3.8:443 action=DENY reason=private_ip
ts=2026-11-07T09:14:06Z partner=P-3391 dst=a3.rebind.example
ip=169.254.169.254:443 action=DENY reason=link_local
One partner, many new host names, each resolving to a different internal address a second apart: that's someone mapping your network through the webhook feature. The proxy stopped it, and the log line tells you which partner account to suspend and investigate. An alert on "more than N denies per partner per hour" is cheap and catches it (detection engineering is L62).
🧠 Extra takeaways
- When URLs are the product, assume SSRF will happen and make it worthless: no role, no internal reach.
- Route customer-URL traffic through one egress proxy that does the IP checks, and block everything else.
- Re-validate at send time, never echo response bodies, and sign outgoing payloads.
- Proxy deny logs are an early-warning signal; alert on bursts of denies per caller.
The quiz for this lesson and the Tests section need JavaScript.