What is Server-Side Request Forgery (SSRF), and how do you defend an endpoint that fetches a user-supplied URL?
A server that fetches a user-supplied URL can be tricked into fetching your own cloud credentials instead — SSRF turns your network position into the attacker's.
LEARN BY DOINGPut your intuition to work3 steps
What is Server-Side Request Forgery (SSRF), and how do you defend an endpoint that fetches a user-supplied URL?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What SSRF actually is
Server-Side Request Forgery happens when an attacker gets your server to make an HTTP (or other protocol) request on their behalf, to a destination the attacker chose rather than one your application intended. The classic setup is an endpoint that legitimately needs to fetch a URL supplied by the caller — a link-preview generator, a webhook tester, an image-import feature, a PDF-from-URL renderer. The attacker doesn't hand you a malicious file; they hand you a URL, and your server does the fetching using its own network identity, from inside your infrastructure.
That last detail is what makes SSRF dangerous rather than merely annoying: your server usually sits somewhere the attacker cannot reach directly — inside a VPC, behind a firewall, on the same private subnet as internal admin panels, databases, and message queues. Many of those internal services trust anything that can reach them on the assumption that "if you're on this network, you already passed some other check." SSRF lets an outside attacker borrow your server's position on that trusted network for a single request.
#Why cloud metadata endpoints make this catastrophic
Every major cloud provider exposes an instance metadata service at a link-local address (most commonly 169.254.169.254) that answers unauthenticated HTTP requests from anything running on that instance — by design, since it's meant to let your own application code fetch its own configuration and temporary credentials without needing a bootstrapped secret. If an SSRF-vulnerable endpoint will fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>, the response often includes a live, temporary access key and secret for whatever IAM role the compute instance runs as — handed straight back to the attacker in the "preview" your endpoint was building.
#A vulnerable endpoint
from flask import Flask, requestimport requestsapp = Flask(__name__)@app.route("/fetch-preview")def fetch_preview():url = request.args.get("url")# DANGEROUS: fetches whatever URL the caller supplies, using the server's own network access.resp = requests.get(url, timeout=5)return resp.text[:2000]
There is nothing exotic about the request path here — no injection, no malformed input. The bug is that the server treats "a URL the user asked me to fetch" as equivalent to "a URL I am willing to send my own credentials and network position toward," and those are not the same thing.
#Fixing it: allowlist the destination, not just the syntax
A URL-shape check (does it start with https://?) stops almost nothing — an attacker can supply http://169.254.169.254/... or a hostname they control that resolves to a private IP, and it will still "look like" a normal URL. The fix has to validate where the request will actually land, not just how the string is formatted.
import ipaddressimport socketfrom urllib.parse import urlparseimport requestsALLOWED_HOSTS = {"images.trusted-partner.com", "cdn.trusted-partner.com"}def is_public_ip(ip: str) -> bool:addr = ipaddress.ip_address(ip)return not (addr.is_privateor addr.is_loopbackor addr.is_link_local # covers 169.254.169.254, the cloud metadata addressor addr.is_multicastor addr.is_reserved)def safe_fetch(url: str) -> requests.Response:parsed = urlparse(url)if parsed.scheme not in ("http", "https"):raise ValueError("scheme not allowed")if parsed.hostname not in ALLOWED_HOSTS:raise ValueError("host not allowlisted")# Resolve before connecting, and check every returned address, not just the first —# a hostname can have multiple A/AAAA records.resolved_ips = {info[4][0] for info in socket.getaddrinfo(parsed.hostname, None)}if not all(is_public_ip(ip) for ip in resolved_ips):raise ValueError("host resolves to a non-public address")# allow_redirects=False: a 302 to http://169.254.169.254/... must never be followed silently.resp = requests.get(url, timeout=5, allow_redirects=False)if resp.is_redirect:raise ValueError("redirects are not followed for user-supplied URLs")return resp
#Layering the defense
No single check is airtight on its own, so SSRF defenses are meant to stack:
| Layer | What it stops |
|---|---|
| Hostname/scheme allowlist | Requests to arbitrary hosts or non-HTTP(S) schemes (file://, gopher://, dict://) |
| Resolve-then-validate the IP | Hostnames that resolve to private, loopback, or link-local addresses |
| Disable redirect-following, or re-validate every hop | An allowlisted host redirecting to a blocked internal address |
| Network-level egress firewall on the app tier | Any bypass in application logic, as a backstop |
| IMDSv2 (session-token-based metadata access) | Naive metadata reads that don't have a valid session token, even if SSRF still occurs |
| A dedicated low-privilege "fetcher" service with no internal network access | The worst-case outcome if every other layer fails |