You asked the server to fetch a URL. It fetched the wrong one.
Plenty of ordinary features need the server to fetch a URL somebody else supplied: a link preview, an avatar imported from a profile picture URL, a webhook tester, a PDF generator that renders a given page. All of them share the same shape, and the same failure mode.
A link preview feature
1@app.route("/fetch-preview", methods=["POST"])2@login_required3def fetch_preview():4 url = request.form["url"]5 response = requests.get(url, timeout=5)6 return {"content": response.text[:500]}
Nothing about this code cares where url points. It could be a news article, which is the intended use. It could also be http://localhost:6379 to probe an internal Redis instance, or http://internal-admin.local/users to reach an internal API that was never meant to face the public internet, and never expected to need to defend itself, because it assumed only requests originating from inside your own network could ever reach it.
That assumption is exactly what this bug breaks. The request really does originate from inside your network, from your own server. The user just gets to pick where it goes, and an internal service that trusts "this request came from inside" as its whole security model has no way to tell the difference between your app doing its job and your app being used as a proxy into somewhere it was never supposed to reach.
The cloud metadata endpoint: SSRF's most expensive payoff
Every major cloud provider gives a running instance a way to ask "what am I," over HTTP, at a fixed internal address that isn't routable from outside the cloud network. On AWS it's 169.254.169.254, and one path on it hands out live credentials.
Fed straight into the vulnerable endpoint from the last section
1POST /fetch-preview2url=http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role-name
That endpoint responds with a real, temporary access key, secret key, and session token for whatever IAM role the instance is running as. No authentication required, because the whole design assumes only code already running on that exact instance could ever ask. SSRF breaks that assumption the same way it breaks "internal means trusted" everywhere else: the request really is coming from the instance, the attacker just chose where the instance points its own outbound call.
This isn't a theoretical worst case. It's the documented mechanism behind the 2019 Capital One breach: an SSRF vulnerability was used to query the AWS metadata service, the resulting temporary credentials were used to access S3 buckets, and the incident exposed data belonging to more than 100 million people. One unvalidated URL turned into one of the largest breaches of the decade.
In practice: enforce IMDSv2 on every EC2 instance (aws ec2 modify-instance-metadata-options --http-tokens required). It requires a PUT request to fetch a session token before any metadata GET works, and caps how many network hops that token can travel. A plain SSRF vector that only forces your server to issue a GETtypically can't reproduce that handshake, which closes off this specific attack even before you've fixed the underlying SSRF bug.
IMDSv2 is a mitigation for one specific target, not a fix for SSRF itself. The actual bug, a server that fetches any URL it's handed, is still there and still worth closing on its own terms.
Allowlisting outbound requests, and the two ways the obvious fix fails
The obvious fix is to check the destination before fetching it. The obvious version of that check has two holes in it that aren't obvious at all.
A validation check that looks complete
1def is_safe_url(url):2 host = urlparse(url).hostname3 ip = socket.gethostbyname(host)4 return not ip.startswith(("10.", "172.16.", "192.168.", "127.", "169.254."))56@app.route("/fetch-preview", methods=["POST"])7def fetch_preview():8 url = request.form["url"]9 if not is_safe_url(url):10 abort(400)11 response = requests.get(url, timeout=5)12 return {"content": response.text[:500]}
This resolves the hostname, checks the IP it gets back, and only proceeds if it looks public. Two things break it. First, DNS rebinding: an attacker who controls DNS for their own domain can set a very short TTL, answer is_safe_url's lookup with a public IP, and answer requests.get's separate lookup, moments later, with 127.0.0.1. Two independent DNS lookups on the same hostname are not guaranteed to return the same answer, and this code relies on them matching.
Second, redirects. requests.get follows them by default. A URL that's completely legitimate at validation time can respond with a 302 to an internal address, and the library happily follows it, past a check that only ever looked at the original URL.
Fixed: resolve once, validate that exact result, don't auto-follow redirects
1PRIVATE_RANGES = [2 ipaddress.ip_network(r) for r in3 ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16")4]56def resolve_and_validate(url):7 host = urlparse(url).hostname8 ip = socket.gethostbyname(host)9 addr = ipaddress.ip_address(ip)10 if any(addr in net for net in PRIVATE_RANGES):11 raise ValueError("blocked: private address")12 return ip # the IP actually validated; connect to this one, don't re-resolve1314@app.route("/fetch-preview", methods=["POST"])15def fetch_preview():16 url = request.form["url"]17 resolve_and_validate(url)18 response = requests.get(url, timeout=5, allow_redirects=False)19 if response.is_redirect:20 abort(400) # or: validate the redirect target the same way, one hop at a time21 return {"content": response.text[:500]}
The important part isn't any single line here, it's the principle: validate the exact network destination the request will actually use, not a URL that might resolve differently by the time the real connection happens, and never let an automatic redirect undo a check you already did. Most teams that need this reliably reach for a library built specifically to pin DNS resolution and re-validate every hop, rather than hand-rolling it per endpoint the way this example does for clarity.
Don't make application code the only thing standing in the way
The allowlist in the last section is real protection, and it's also hand-written validation logic with edge cases in it, which is exactly the category of code this whole article is about. Treat it as one layer, not the only one.
The layer underneath it is network-level, not application-level: firewall or security group rules that block your application server from reaching internal-only ranges and the metadata endpoint at all, regardless of what URL your code is told to fetch. If the network path to 169.254.169.254 or your internal admin API simply doesn't exist from the box running this feature, a bug in the allowlist stops being able to reach anything worth stealing, no matter how cleverly it's exploited.
Some teams go further and run any "fetch a URL the user gave us" feature on a dedicated, egress-restricted worker with no route into the rest of the internal network at all, separate from the servers that actually hold credentials and talk to internal services. A full SSRF exploit against that worker still only reaches the public internet it was already allowed to reach. This is the same instinct as least-privilege database accounts from the injection article: assume the check you wrote will eventually miss a case, and make sure that when it does, there's a smaller blast radius waiting on the other side of it, not the whole network.