Secure a Download Endpoint Against SSRF
Build an endpoint that downloads a file from a user-supplied URL and harden it against Server-Side Request Forgery: HTTPS-only, private-IP blocking, redirect revalidation, size/timeout/content-type limits, and network-mocked tests.
Fork or clone it (Ruby/Rails, Python/FastAPI or TypeScript) and make the failing tests pass.
Problem
Your application needs a feature where a user pastes a URL — say, an avatar image or an import file — and the server fetches it. This is one of the most dangerous features you can build, because the request originates **from inside your infrastructure**, with your server's network position and trust. A naive `fetch(file_url)` is a classic **Server-Side Request Forgery (SSRF)** vulnerability. An attacker doesn't need to reach your internal network directly — they just hand your server a malicious URL and let it do the reaching for them. Concretely, an attacker can point `file_url` at: - **Cloud metadata endpoints** — `http://169.254.169.254/latest/meta-data/` on AWS/GCP/Azure. On an unpatched IMDSv1 instance this leaks temporary IAM credentials, which is a full compromise of your cloud account. - **Internal services** — `http://10.0.0.5:6379/` (Redis), `http://localhost:9200/` (Elasticsearch), admin panels, databases, and health/debug endpoints that were only ever meant to be reachable from inside the VPC. - **Link-local and loopback ranges** — `127.0.0.1`, `169.254.0.0/16`, `[::1]` — to probe services bound to the box itself. - **Non-HTTP schemes** — `file:///etc/passwd`, `gopher://`, `ftp://` — to read local files or smuggle raw bytes into other protocols. In this lab you'll build the download endpoint **and then attack it**, so you feel why each defense exists. Then you'll layer the defenses: HTTPS-only, DNS resolution with private-range blocking, redirect revalidation, hard limits on size/time/content-type, and a suite of tests that prove each block holds. > This lab is concept-first and stack-agnostic. Examples use pseudocode and > concrete IP ranges; translate them to your language's HTTP client, DNS > resolver, and test/mocking tools.
Objectives
By the end of this lab you will be able to:
- Explain how SSRF turns a "fetch a URL" feature into an attack against your
own internal network and cloud metadata. - Reject dangerous URL schemes and enforce HTTPS-only fetching.
- Resolve a hostname via DNS and block requests that resolve to private,
loopback, or link-local IP ranges (IPv4 and IPv6). - Follow redirects safely by revalidating the target host on every hop.
- Enforce hard limits: maximum response size (Content-Length and streamed
bytes), request timeout, and a content-type allowlist. - Write network-mocked tests that prove each block (non-HTTPS, private IP,
malicious redirect, oversized file) actually fails closed.
Prerequisites
To complete this lab you'll need:
- A backend project in any language/stack with an HTTP client, a DNS resolver
(or a way to resolve a host to its IPs), and a background job/worker system. - A test framework and a way to mock or stub network calls (e.g. a local
HTTP fixture server, a request-interception library, or dependency injection
of the HTTP client). - Basic familiarity with URLs, IP addressing, and CIDR notation.
- The ability to run a background job — the fetch must not run inline in the
web request.
You do not need a real vulnerable server to attack; you'll simulate the
dangerous targets with local/mocked endpoints.
The mental model: your server is the confused deputy
SSRF is a confused deputy problem. Your server is a trusted actor inside the
network perimeter. When it fetches a URL on behalf of an untrusted user, it
lends that user its trust and its network position. The fix is not to sprinkle
a blocklist of "bad domains" — attackers bypass string blocklists trivially
(encodings, DNS tricks, 0x7f.0.0.1, decimal IPs). The fix is to decide, on
the real resolved IP, whether this destination is allowed at all.
A robust SSRF defense is a small pipeline, and order matters:
user URL
│
▼ 1. parse + require https:// scheme
▼ 2. resolve host → IPs (DNS)
▼ 3. reject if ANY resolved IP is private/loopback/link-local
▼ 4. connect; on each redirect, GOTO step 1 for the new URL
▼ 5. enforce timeout, max size, content-type allowlist
▼
file bytes (or a safe, generic error)
The ranges you must block
These are the non-routable and internal ranges. If a hostname resolves to any
of them, refuse the fetch:
| Family | Range | Why |
|---|---|---|
| IPv4 | 127.0.0.0/8 |
Loopback (localhost, internal daemons) |
| IPv4 | 10.0.0.0/8 |
Private network |
| IPv4 | 172.16.0.0/12 |
Private network |
| IPv4 | 192.168.0.0/16 |
Private network |
| IPv4 | 169.254.0.0/16 |
Link-local — includes cloud metadata 169.254.169.254 |
| IPv4 | 0.0.0.0/8 |
"This host" / unspecified |
| IPv6 | ::1/128 |
Loopback |
| IPv6 | fc00::/7 |
Unique local (private) |
| IPv6 | fe80::/10 |
Link-local |
Security note —
169.254.169.254: the cloud metadata service is the single
highest-value SSRF target. On AWS/GCP/Azure it answers on this link-local IP and,
on legacy IMDSv1, hands out temporary credentials to anyone who asks. Blocking
the whole169.254.0.0/16(andfe80::/10) range is non-negotiable.
Fail closed, and stay quiet
Two cross-cutting rules apply to every step:
-
Run in the background. Do the fetch in a job/worker, never inline in the
web request. Inline fetches let an attacker use response timing and error
differences as an oracle to map your internal network, and they tie up your
web threads for a DoS. -
Never echo the upstream error or body to the user. Return a generic
"could not fetch that URL." A leaked connection-refused vs. timeout vs. 200
tells the attacker exactly what's listening internally. Log details
server-side; show nothing useful to the caller.
Work through the six steps below in order. Each one builds on the last.
Steps
-
Build the naive endpoint — and attack it
Start by building the vulnerable version, so you can watch it fail.
Create an endpoint that accepts afile_urland downloads it:POST /downloads body: { "file_url": "https://example.com/report.pdf" } def create(file_url): response = http.get(file_url) # <-- naive, follows anything saved = storage.write(response.body) return { id: saved.id }Now be the attacker. Point
file_urlat targets that should be off-limits
and confirm the naive endpoint happily reaches them:# 1) Cloud metadata — the crown jewel file_url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/" # 2) Internal service on the box file_url = "http://127.0.0.1:6379/" # Redis # 3) Local file read via non-HTTP scheme file_url = "file:///etc/passwd"Observe that the naive handler:
- follows any scheme its HTTP client supports,
- resolves and connects to any IP, including internal ones,
- follows redirects blindly,
- and may echo the upstream body or error straight back to the caller.
Write down, for each attack above, what the endpoint returned. That's your
"before" evidence. The next five steps close each hole. Do not deploy this
version — it exists only to demonstrate the attack in a controlled setting. -
Require HTTPS and validate the URL
The first gate is the URL itself. Parse it with a real URL parser (never
with string matching / regex on the raw input) and enforce a strict shape.ALLOWED_SCHEMES = { "https" } def validate_url(raw): url = parse(raw) # real parser; reject on parse error if url.scheme not in ALLOWED_SCHEMES: # blocks http, file, gopher, ftp, data... reject("scheme not allowed") if url.host is empty: reject("missing host") if url.userinfo present: # e.g. https://user@evil@internal/ reject("credentials in URL not allowed") return urlWhy each rule:
-
HTTPS-only kills
file://(local file read),gopher://andftp://
(protocol smuggling), and plaintexthttp://. An allowlist of one scheme
is far safer than a blocklist you have to keep complete. -
Reject embedded credentials (
userinfo):https://trusted.com@169.254.169.254/
parses with host169.254.169.254in a correct parser, but sloppy code
that reads up to the@gets fooled. Rejectinguserinforemoves the
ambiguity entirely. -
Require a non-empty host so relative or malformed inputs can't slip
through.
This step alone stops attacks #1's
http://variant and #3'sfile://
entirely. Buthttps://169.254.169.254/is still valid HTTPS pointing at
metadata — that's the next step's job.Do not try to validate the IP by inspecting the hostname string here.
https://localhost/,https://0x7f000001/, and a domain whose DNS record
points at127.0.0.1all look different as strings. IP filtering must
happen on the resolved address (Step 3), not on the text. -
HTTPS-only kills
-
Resolve DNS and block private/loopback/link-local IPs
This is the core defense. Resolve the hostname to its actual IP addresses
and refuse the fetch if any resolved address falls in a blocked range.BLOCKED_V4 = [ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "0.0.0.0/8", ] BLOCKED_V6 = [ "::1/128", "fc00::/7", "fe80::/10" ] def assert_public_host(host): ips = dns_resolve(host) # may return several A/AAAA records if ips is empty: reject("host does not resolve") for ip in ips: for cidr in BLOCKED_V4 + BLOCKED_V6: if ip in cidr: reject("destination is a private/internal address") return ipsKey details that make this correct:
-
Check every resolved IP, not just the first. A hostname can return
multiple A/AAAA records; an attacker can list one public and one internal.
If any is internal, reject. -
Use a numeric IP-in-CIDR check, not string comparison. Parse the IP to
its integer/bytes form and test containment. This is immune to
0x7f.0.0.1,2130706433(decimal127.0.0.1), and zero-padding tricks —
they all parse to the same blocked address. -
Cover IPv6 and IPv4-mapped IPv6 (
::ffff:127.0.0.1). Normalize mapped
addresses to their IPv4 form before checking, or block the mapped range too.
Now
https://169.254.169.254/is refused, and so is any domain an attacker
registers that resolves to127.0.0.1or10.x.x.x.Advanced — DNS rebinding & TOCTOU. There's a gap between resolving the
host and connecting: an attacker's DNS can return a public IP for the
check, then a private IP microseconds later for the actual connection
(a time-of-check/time-of-use race). The strongest fix is to resolve once,
pick a validated IP, and connect to that exact IP while sending the
originalHostheader — so the connection can't be re-pointed. Note this
as a hardening goal; the per-hop revalidation in Step 4 also narrows the window. -
Check every resolved IP, not just the first. A hostname can return
-
Handle redirects with per-hop host revalidation
A single validated URL isn't enough:
https://evil.com/startcan return a
302 Location: http://169.254.169.254/. If your HTTP client auto-follows
redirects, all your Step 2–3 work is bypassed on the second hop.Turn off automatic redirect following and drive it yourself, revalidating
every hop:MAX_REDIRECTS = 2 def fetch_guarded(url): for hop in 0..MAX_REDIRECTS: validate_url(url) # Step 2: https-only, no userinfo ips = assert_public_host(url.host) # Step 3: block private ranges resp = http.get(url, follow_redirects=false, connect_to=ips) if resp.is_redirect: url = resolve_relative(url, resp.header["Location"]) continue # re-run validation on the NEW url return resp reject("too many redirects")The critical rule: every hop goes through the full Step 2 + Step 3 gauntlet
again. A redirect target is just another user-influenced URL. Cap the hop
count (2 is plenty for legitimate use) so a redirect loop can't spin forever.Common mistakes this prevents:
- Trusting the client's built-in redirect handling (validates hop 0 only).
- Validating the original host but connecting to the redirected one.
- Allowing unlimited hops (
Locationbouncing as a slow-loris / DoS).
With redirects revalidated, attack "public domain → 302 → metadata IP" now
fails on the second hop exactly as a direct request would. -
Enforce size, timeout, and content-type limits
Even a fully validated public URL can be hostile: a huge file, a slow stream,
or an unexpected type. Add resource limits so the fetch can't be turned into
a denial-of-service or a type-confusion vector.MAX_BYTES = 100 * 1024 * 1024 # 100 MB TIMEOUT = 30_seconds # total, including connect + read ALLOWED_TYPES = { "application/pdf", "image/png", "image/jpeg" } def read_limited(resp): # 1) Trust nothing: check the header AND the actual stream if resp.header["Content-Length"] and int(it) > MAX_BYTES: reject("file too large") ctype = resp.header["Content-Type"].split(";")[0].strip().lower() if ctype not in ALLOWED_TYPES: reject("content-type not allowed") # 2) Stream and hard-cut — Content-Length can lie or be absent total = 0 for chunk in resp.stream(TIMEOUT): total += len(chunk) if total > MAX_BYTES: abort_connection() reject("file too large") buffer.write(chunk) return bufferWhy each limit:
-
Check Content-Length and cap the stream. The header is a hint an
attacker controls — it can lie, be absent, or under-report. Counting bytes
as you stream and aborting past the cap is the real defense. This also
blunts decompression bombs (a small gzipped body that expands to
gigabytes): cap the decompressed bytes. -
Total timeout. A server that trickles one byte per second holds your
worker hostage (slow-loris). Bound connect + read time. -
Content-type allowlist. If you asked for an avatar image, refuse
text/htmlorapplication/octet-stream. This narrows what a compromised
upstream can feed into downstream parsers.
Combined with the background-job rule, these limits keep one malicious URL
from exhausting memory, threads, or time. -
Check Content-Length and cap the stream. The header is a hint an
-
Prove it with network-mocked tests (submission)
A security control you can't test will rot. Write tests that mock the
network and assert each defense fails closed. Mocking lets you simulate
dangerous responses (a redirect to metadata, an oversized body) without
standing up real malicious infrastructure.Cover, at minimum, these cases — each should raise a rejection / return the
generic error, never fetch the forbidden target:test "rejects non-https scheme": expect_reject(fetch_guarded("http://example.com/x")) expect_reject(fetch_guarded("file:///etc/passwd")) test "rejects host resolving to a private / link-local IP": stub_dns("evil.test" => ["169.254.169.254"]) # cloud metadata expect_reject(fetch_guarded("https://evil.test/")) stub_dns("lan.test" => ["10.0.0.5"]) expect_reject(fetch_guarded("https://lan.test/")) test "rejects a redirect that points at an internal IP": stub_http("https://ok.test/" => redirect_to("http://169.254.169.254/")) expect_reject(fetch_guarded("https://ok.test/")) test "rejects an oversized file (header and streamed)": stub_http("https://big.test/" => body_of(200 MB)) expect_reject(fetch_guarded("https://big.test/")) test "rejects a disallowed content-type": stub_http("https://ok.test/" => html_response()) expect_reject(fetch_guarded("https://ok.test/")) test "allows a well-formed public https file": stub_dns("cdn.test" => ["93.184.216.34"]) # public IP stub_http("https://cdn.test/a.pdf" => pdf_response(1 MB)) expect_success(fetch_guarded("https://cdn.test/a.pdf")) test "never leaks the upstream error to the caller": stub_http("https://ok.test/" => connection_refused()) resp = endpoint_download("https://ok.test/") expect(resp.body) == generic_error() # no timing/error oracleAlso assert the cross-cutting rules: the fetch runs in a background job
(the web request returns immediately), and the redirect hop cap is enforced.
Submission criteria
Submit when all of the following hold:
- A
/downloads-style endpoint that acceptsfile_url, enqueues a
background job, and returns a generic response (no upstream detail). - The guarded fetch enforces, in order: HTTPS-only + URL validation →
DNS resolution with private/loopback/link-local blocking → per-hop
redirect revalidation (cap ~2) → size + timeout + content-type limits. - A passing test suite with network mocked, proving each block:
non-HTTPS, private/link-local IP (including169.254.169.254), malicious
redirect, oversized file, disallowed content-type — plus one happy-path
public fetch and one "error is not leaked" test.
Include a short note on how you'd address DNS rebinding (connect-by-IP
with a pinnedHost) as a future hardening step. - A