Web Application Security · intermediate · ~11 min
**What you will learn** - Explain what SSRF is and why a request coming *from the server* is dangerous — it crosses trust boundaries the attacker cannot cross directly. - Identify the features most likely to contain SSRF (URL import, webhooks, PDF/image generators, link previews) and describe the cloud-metadata escalation path. - Build a **destination allowlist** that validates the *resolved* IP address, not just the hostname string. - Explain why blocklists of "internal" ranges fail (DNS rebinding, redirects, alternate IP encodings) and how to defend against each. - Write a **mitigation-verification test** that proves your fix rejects internal targets and still accepts legitimate ones. - Decide what to log for SSRF detection without leaking secrets, and require IMDSv2 for cloud metadata.
Security objective. The asset you are protecting is your internal network and cloud identity — internal admin panels, databases, and above all the cloud instance-metadata service that can hand out IAM credentials. The threat is an attacker who cannot reach those things directly, but can make your server reach them. By the end of this lesson you will be able to detect an SSRF-prone URL-fetching feature, remediate it with an allowlist that validates resolved IPs, and verify the fix.
Server-side request forgery (SSRF) is a vulnerability where an attacker controls the destination of a request that your server makes. Your app has a feature like "import an image from this URL" or "call this webhook." The attacker supplies a URL that points not at the public internet but at something private — http://169.254.169.254/, http://localhost:5432/, an internal admin page — and your server dutifully fetches it and often hands the response back.
This builds directly on your prereqs. From Testing the web: intercepting and shaping requests you already know how to see and modify the exact request a client sends; SSRF is about the request the server sends on your behalf, so the same interception mindset applies one hop deeper. From IP addresses: IPv4, IPv6, public vs private you know the private ranges (10/8, 172.16/12, 192.168/16, 127/8, link-local 169.254/16). The whole defense hinges on that knowledge: a correct SSRF filter decides reject or allow by looking at the resolved IP and asking "is this a private / loopback / link-local address?"
SSRF is consistently ranked among the most serious web vulnerabilities (it has its own category in the OWASP Top 10) precisely because of its blast radius. A single unvalidated URL parameter on a public web app can become a pivot into an entire private network.
In cloud environments the escalation is dramatic. Most cloud providers expose an instance-metadata service at the link-local address 169.254.169.254. On a misconfigured instance it returns temporary IAM credentials for the role attached to that machine. An attacker who can make your server request http://169.254.169.254/latest/meta-data/iam/security-credentials/ can read those credentials out of the HTTP response, then use them to talk to cloud APIs as your server — reading storage buckets, spinning up resources, or moving laterally. A read-only image-preview feature turns into full account compromise.
In authorized professional work you will meet SSRF from both sides: as a defender hardening URL-fetch features and requiring IMDSv2, and as a penetration tester who must demonstrate impact safely on systems you are authorized to test. The single most important lesson to internalize is that the correct fix is allowlisting by resolved IP, not blocklisting strings — and this lesson shows exactly why.
Definition. SSRF exists whenever your server makes an outbound request to a location influenced by user input.
Plain explanation. The attacker's browser cannot reach 10.0.0.5 or 169.254.169.254 — those are inside your network. But your server is inside your network. If the attacker can choose the URL your server fetches, they borrow your server's network position.
How it works. A feature accepts url=..., the server calls an HTTP client on that URL, and (in the classic "full-response" SSRF) returns the body to the user. Even "blind" SSRF, where the body is not returned, is dangerous: timing and error differences leak internal port/host existence, and some internal endpoints perform actions on a mere GET.
When it appears / when not. It appears in any feature that fetches a user-supplied URL. It does not apply to requests the user makes to you — that is normal traffic. The tell is: does my server open a connection to a place the user named?
Pitfall. Assuming "we only fetch images" makes you safe. The server still connects to the host before it knows the content type; the connection itself is the danger.
| Target | Why it matters |
|---|---|
169.254.169.254 (link-local) |
Cloud metadata → IAM credentials |
127.0.0.1 / localhost |
Admin panels, unauthenticated internal APIs bound to loopback |
10/8, 172.16/12, 192.168/16 |
Internal databases, dashboards, other microservices |
file://, gopher://, dict:// |
Read local files or craft raw protocol traffic |
A naive fix checks the hostname string against a denylist. Attackers bypass it:
127.0.0.1 also = 2130706433 (decimal), 0177.0.0.1 (octal), [::1] (IPv6), 127.1.302 Location: http://169.254.169.254/..., and a client that follows redirects walks straight into the internal target.http://expected.com@169.254.169.254/, or a domain the attacker points at 169.254.169.254.The robust answer is to resolve the host to its IP(s), reject any that fall in private/loopback/link-local ranges, connect to that validated IP, and re-check on every redirect.
file:, gopher:, dict:) — allow only https: (and http: if truly required).THREAT MODEL — "Import image from URL" feature
[ Attacker browser ]
| POST /fetch url=<attacker-chosen>
v
=== TRUST BOUNDARY (public edge) ==========================
|
[ Web app server ] <-- entry point: url parameter
| \
| (allowed) \ (SSRF: attacker-chosen internal target)
v v
[ example.com ] === INTERNAL TRUST BOUNDARY ===============
(public) | | |
v v v
169.254.169.254 10.0.0.5:5432 127.0.0.1:8080
(metadata/IAM) (database) (admin panel)
Assets protected: internal services + cloud IAM credentials
Entry point: the user-supplied url
Insecure assumption: "the URL string looks external"
Knowledge check.
169.254.169.254.169.254.169.254.The core defensive primitive is: resolve the host, then test the IP against private ranges before connecting. Language-independent shape:
# Pseudocode / Python-flavored: validate a URL destination
allowed_schemes = {"https"} # deny file:, gopher:, dict:, even http if possible
allowed_hosts = {"images.example.com"} # exact allowlist, deny by default
url = parse(user_input)
assert url.scheme in allowed_schemes # (1) scheme gate
assert url.host in allowed_hosts # (2) host allowlist
for ip in resolve(url.host): # (3) resolve to ALL IPs
assert not is_private(ip) # (4) reject private/loopback/link-local
connect_to(ip, disallow_redirects=True) # (5) pin the IP, no redirect follow
The five gates in order: scheme → host allowlist → resolve → IP check → pinned connect with redirects off. Skipping gate (3)/(4) is what leaves you open to DNS and encoding tricks; skipping the redirect rule reopens it after the fact.
SSRF (server-side request forgery) tricks the server into making a request to a location the attacker chooses. Because the request originates from the server, it reaches places the attacker cannot reach directly.
Any feature that fetches a user-supplied URL is a candidate:
Here is the difference between intended use and abuse:
POST /fetch url=https://example.com/logo.png ← intended
POST /fetch url=http://169.254.169.254/latest/meta-data/ ← SSRF
The server obediently fetches the second one too.
The classic target is the cloud metadata endpoint http://169.254.169.254/.
On misconfigured instances, it returns IAM credentials. That turns a single SSRF into full cloud account compromise.
SSRF can also reach:
http://localhost resourcesfile:// resourcesfile: and gopher:).The example is a Python Flask-style URL-fetch endpoint. It is a concept-track lesson, so we use HTTP/Python, not C. Run it only against localhost in an isolated lab.
# vulnerable_fetch.py -- LAB ONLY, DO NOT DEPLOY
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route("/fetch")
def fetch():
url = request.args.get("url", "")
# BUG: no validation at all. The server will connect anywhere,
# follow redirects, and hand the body back to the caller.
r = requests.get(url, timeout=3) # follows redirects by default
return r.text
An attacker calls GET /fetch?url=http://169.254.169.254/latest/meta-data/ and the server returns the metadata index; point it at the IAM path and it returns credentials. GET /fetch?url=http://127.0.0.1:8080/admin reaches loopback-only admin panels. A blocklist of the string 169.254.169.254 would still be bypassed by http://2852039166/ or a redirect.
# safe_fetch.py -- allowlist + resolved-IP validation
import ipaddress, socket
import requests
from urllib.parse import urlparse
from flask import Flask, request, abort
app = Flask(__name__)
ALLOWED_SCHEMES = {"https"}
ALLOWED_HOSTS = {"images.example.com"} # deny everything else by default
def resolved_ips(host):
# Return every IP the host resolves to (IPv4 and IPv6).
infos = socket.getaddrinfo(host, None)
return {info[4][0] for info in infos}
def is_blocked_ip(ip_str):
ip = ipaddress.ip_address(ip_str)
return (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
def validate(url):
u = urlparse(url)
if u.scheme not in ALLOWED_SCHEMES: # gate 1: scheme
abort(400, "scheme not allowed")
if u.hostname not in ALLOWED_HOSTS: # gate 2: host allowlist
abort(400, "host not allowed")
ips = resolved_ips(u.hostname) # gate 3: resolve to all IPs
if not ips or any(is_blocked_ip(ip) for ip in ips):
abort(400, "destination resolves to a blocked address") # gate 4
return u.hostname
@app.route("/fetch")
def fetch():
url = request.args.get("url", "")
validate(url)
# gate 5: no redirect following -- a redirect could jump to an internal IP.
r = requests.get(url, timeout=3, allow_redirects=False)
if 300 <= r.status_code < 400:
abort(400, "redirects are not allowed")
return r.text
Note on residual risk (DNS rebinding). Even here, getaddrinfo at validate-time and the socket the requests library opens at fetch-time are two separate lookups (TOCTOU). A fully hardened version resolves once, verifies the IP, and pins the connection to that exact IP (e.g., a custom connection adapter or passing the IP as the connect target with the Host header set). For this lesson the allowlist of exact hosts plus IP validation is the teachable core; the pinning note tells you where the last gap is.
# test_safe_fetch.py -- run against the lab server on localhost
import requests
BASE = "http://127.0.0.1:5000/fetch"
def expect(url, want_status):
r = requests.get(BASE, params={"url": url})
print(f"{r.status_code:>3} (want {want_status}) {url}")
assert r.status_code == want_status
# BAD input must be REJECTED (400)
expect("http://169.254.169.254/latest/meta-data/", 400) # metadata / wrong scheme
expect("http://127.0.0.1:8080/admin", 400) # loopback
expect("file:///etc/passwd", 400) # dangerous scheme
expect("https://evil.example.org/", 400) # host not allowlisted
# GOOD input must be ACCEPTED (200) -- images.example.com must be a
# lab host that resolves to a PUBLIC test address you control.
expect("https://images.example.com/logo.png", 200)
print("All SSRF guard checks passed.")
Expected behaviour. Every malicious URL returns 400 (rejected before any internal connection is made), and the one allowlisted public host returns 200. If a 400 line prints for the good URL, your allowlist or DNS setup is wrong; if a 200/500 prints for a bad URL, the guard has a hole — investigate before trusting it.
Walking through safe_fetch.py's validate() — the heart of the defense:
| Step | Code | What happens |
|---|---|---|
| Parse | u = urlparse(url) |
Splits the input into scheme, hostname, port, path so each part can be checked separately. |
| Gate 1 | if u.scheme not in ALLOWED_SCHEMES |
Rejects file:, gopher:, dict:, and plain http: — only https survives. Stops local-file reads and raw-protocol abuse. |
| Gate 2 | if u.hostname not in ALLOWED_HOSTS |
Deny-by-default host allowlist. evil.example.org and 169.254.169.254 (as a literal host) both fail here. |
| Gate 3 | ips = resolved_ips(u.hostname) |
Resolves the name to every address (IPv4 + IPv6). This is why a hostname secretly pointing at a private IP is caught: we look at what it actually resolves to, not the string. |
| Gate 4 | any(is_blocked_ip(ip) for ip in ips) |
Rejects if any resolved IP is private, loopback, link-local, reserved, multicast, or unspecified. Uses the ranges from your IP-addressing prereq. |
| Gate 5 | allow_redirects=False + 3xx abort |
Even a valid first hop cannot bounce you into 169.254.169.254 via a 302. |
How values change on a malicious input http://169.254.169.254/latest/meta-data/:
u.scheme = "http" → not in {"https"} → abort(400) immediately. The connection is never opened.On a rebinding-style input https://images.example.com/ where the attacker has pointed that name at 10.0.0.5:
https ok. 2. Host is in the allowlist (that is the point of the attack) → passes gate 2. 3. resolved_ips returns {"10.0.0.5"}. 4. is_blocked_ip("10.0.0.5") is True (private) → abort(400). The resolved-IP check, not the string check, is what saves you — which is exactly why blocklisting strings fails and IP validation works.Mistake 1 — Blocklisting hostname strings.
if "169.254.169.254" in url or "localhost" in url: reject.http://2852039166/, http://[::ffff:a9fe:a9fe]/), by DNS names that resolve to the target, and by redirects.169.254 — its presence is a smell.Mistake 2 — Validating the string but following redirects.
url correctly, then call an HTTP client that follows redirects by default.302 Location: http://169.254.169.254/... and the client walks into the internal target after your check passed.allow_redirects=False (or re-run full validation on every redirect target).Mistake 3 — Checking the hostname, connecting by name (TOCTOU / DNS rebinding).
Mistake 4 — Forgetting IPv6 and the unspecified/reserved ranges.
10/8, 172.16/12, 192.168/16, 127/8.::1, IPv4-mapped IPv6 ::ffff:127.0.0.1, link-local 169.254/16 and fe80::/10, and 0.0.0.0.is_private or is_loopback or is_link_local or is_reserved or is_multicast or is_unspecified) covering both families.Symptom: the good/allowlisted URL is rejected (400).
ALLOWED_HOSTS (no trailing dot, correct case — hostnames are case-insensitive but your set compare is not; normalise to lowercase).python -c "import socket; print(socket.getaddrinfo('images.example.com', None))" and check the IP is genuinely public. In a lab, images.example.com may resolve to a private test IP and be blocked correctly — point it at a public test address or adjust is_blocked_ip for the lab.Symptom: a malicious URL is accepted (200/500 instead of 400).
validate() and re-run the failing case to see which gate let it through.allow_redirects=False is actually set and the 3xx abort is present.Symptom: intermittent pass/fail on the same URL.
Questions to ask when it fails:
Security & safety — detection and logging for SSRF.
What to log on every URL-fetch attempt (defensive telemetry):
| Field | Example | Why |
|---|---|---|
| Timestamp (UTC) | 2026-07-07T14:03:22Z |
Ordering, correlation |
| Source | authenticated user id / API key id, source IP | Attribution |
| Requested resource | the submitted URL and the resolved IP | The resolved IP is what actually matters |
| Security decision | allowed / blocked: private-ip / blocked: scheme |
Which gate fired |
| Result | HTTP status, bytes returned | Blind-SSRF probing shows as many blocked attempts |
| Correlation id | request id | Ties app log to outbound-connection log |
What to NEVER log: the response body of a fetch (it may contain metadata credentials, tokens, or PII), IAM/session tokens, passwords, private keys, cookies, or full metadata responses. Log the decision and the destination, not the secret you were protecting.
Events that signal abuse:
169.254.169.254, loopback, or RFC1918 ranges — especially 169.254.169.254, which has no legitimate reason to appear in a URL-fetch feature.blocked decisions across many different internal IPs/ports from one user = internal port scan (blind SSRF).How false positives arise: a legitimate integration whose host genuinely lives on an internal IP (e.g., an internal service you do want to call) will be blocked by the private-IP rule. Handle it with an explicit, separately-reviewed internal allowlist entry — never by weakening the global rule. Corporate split-horizon DNS can also make a public name resolve internally; log the resolved IP so you can tell a real attack from a DNS quirk.
Cloud specifics: require IMDSv2 (session-token metadata, PUT then GET with a header), which defeats the simple GET 169.254.169.254 SSRF, and attach least-privilege roles so a leaked credential can do little. Never claim the feature is "completely secure" — say it is hardened against known SSRF classes and monitored.
Authorized real-world use case. A SaaS product offers "add a profile picture from a URL." A security engineer reviews it, finds it fetches arbitrary URLs and follows redirects, and — on a staging environment they are authorized to test — demonstrates that ?url=http://169.254.169.254/latest/meta-data/ returns metadata. They file a finding, ship the allowlist + resolved-IP guard + IMDSv2 requirement, and re-test to confirm the same request now returns 400. All of this happens on infrastructure the company owns.
Professional best-practice habits:
| Habit | How it applies to SSRF |
|---|---|
| Input validation | Validate the resolved IP, deny by default, allowlist exact hosts/schemes. |
| Least privilege | Minimal IAM role on the instance so a leaked credential is nearly useless. |
| Secure defaults | Redirects off, file:/gopher: disabled, https only. |
| Logging | Record decision + resolved IP; alert on any private/link-local destination. |
| Error handling | Fail closed (block on resolve failure), return a generic 400, never echo internal responses. |
Beginner vs advanced:
All tasks are lab-only: run on localhost, containers, or an intentionally-vulnerable VM you own. Authorization checklist before you start: (1) you own or have explicit written permission for the target; (2) it is isolated from production and the internet where possible; (3) you have a rollback/reset plan. Cleanup/reset: stop the lab server, remove any test containers (docker rm -f <name>), and clear logs you generated.
Beginner 1 — Spot the sink.
vulnerable_fetch.py from this lesson, list every reason it is unsafe (no scheme gate, no host allowlist, no IP check, follows redirects, returns body).Beginner 2 — Scheme + host allowlist.
https and any host not in a one-entry allowlist; return 400.?url=file:///etc/passwd → 400; ?url=https://images.example.com/x → passes to fetch.urlparse.Intermediate 1 — Resolved-IP guard.
127.0.0.1 must be rejected even though its name looks fine.ipaddress); no string matching on IPs.socket.getaddrinfo returns all addresses.Intermediate 2 — Redirect defense + detection log.
allow_redirects=False, abort on 3xx, and log timestamp/source/resolved-IP/decision (never the response body).127.0.0.1 → 400; log shows blocked: redirect.Location header at a private IP.Challenge — Full mitigation verification suite.
test_safe_fetch.py with cases for alternate IP encodings (decimal/octal/IPv6), a file:// URL, a redirect-to-private, and one legitimate allowlisted host; assert exact status codes.(url, expected_status) list; run it in CI so regressions are caught.Main concepts. SSRF lets an attacker choose the destination of a request your server makes, borrowing your server's network position to reach internal services and — most dangerously — the cloud metadata endpoint 169.254.169.254, which can yield IAM credentials and full account compromise.
Key syntax/commands. Validate in five ordered gates: scheme allowlist (https only) → exact host allowlist (deny by default) → resolve the host to all IPs → reject any private/loopback/link-local/reserved IP (both IPv4 and IPv6) → connect with redirects disabled (and ideally pin the validated IP).
Common mistakes. Blocklisting hostname strings (bypassed by encodings, DNS, redirects); validating the string but following redirects; checking the name but connecting by name (DNS-rebinding TOCTOU); forgetting IPv6 and reserved ranges.
What to remember. The correct fix is an allowlist validated by resolved IP, not a blocklist of strings. Always verify the fix (bad input → 400, good input → 200), log the decision and resolved IP but never the response body or any secret, require IMDSv2, apply least privilege, and only ever test on systems you own or are authorized to test. Nothing is ever "completely secure" — it is hardened and monitored.