Server-Side Request Forgery (SSRF) — when the server becomes the attacker's proxy
A feature that fetches a URL the user provides is genuinely useful — an avatar-from-URL uploader, a webhook tester, a PDF-from-webpage generator — but it also hands an attacker the ability to make YOUR server issue requests on their behalf, from a network position the attacker could never reach directly.
4 min read
The feature that creates the vulnerability: fetching a user-supplied URL
@app.route("/fetch-avatar", methods=["POST"])
def fetch_avatar(request):
image_url = request.json["url"] # attacker-controlled — any URL at all
response = requests.get(image_url) # the SERVER makes this request, not the user's browser
save_avatar(response.content)This endpoint is a real, legitimate feature — let users set an avatar by pasting an image URL — but it also means the server itself will issue an HTTP request to whatever URL is provided, with no restriction on what that URL actually points to. An attacker isn't limited to real image URLs; they can supply anything the server is capable of reaching.
Why this is dangerous specifically because the SERVER is making the request, not the attacker
image_url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
# This is the AWS/cloud metadata endpoint — reachable only from WITHIN
# the cloud provider's internal network, something an external attacker
# could NEVER reach directly over the public internet — but the SERVER,
# running inside that cloud environment, absolutely can reach itThe genuinely dangerous part isn't that the attacker can make an HTTP request — anyone can already do that from their own machine. It's that the server is making the request, from the server's own network position — which can include internal-only services, cloud metadata endpoints that leak real credentials, internal admin panels with no external exposure, or other machines on a private network the attacker has no direct route to at all. SSRF turns your own server into a proxy the attacker uses to reach places they otherwise structurally cannot.
Why a simple domain blocklist is a real, common, incomplete fix
BLOCKED_HOSTS = ["localhost", "127.0.0.1", "169.254.169.254"]
if urlparse(image_url).hostname in BLOCKED_HOSTS:
raise ValueError("Blocked")
# BYPASSED by: http://127.0.0.1:80 (with a port), a DNS name that RESOLVES
# to 127.0.0.1 (attacker-controlled DNS), decimal/octal IP encoding
# (2130706433 == 127.0.0.1), or a redirect chain (a URL that itself
# 302-redirects to an internal address, AFTER the check already passed)Blocking specific known-dangerous hostnames sounds reasonable but is genuinely easy to bypass: an attacker can register a DNS name that resolves to an internal IP, encode an IP address in a non-obvious numeric format that still resolves correctly, or supply a URL that passes the check but then issues an HTTP redirect to an internal address once the server actually follows it — the blocklist only checked the original URL, not where a redirect chain ultimately leads. This is a real, recurring pattern across security fixes in this domain: a blocklist checking for known-bad patterns is fundamentally more fragile than an allowlist restricting to known-good ones.
A more robust approach: allowlist expected destinations, and resolve DNS before checking
import socket
import ipaddress
def is_safe_url(url):
hostname = urlparse(url).hostname
resolved_ip = socket.gethostbyname(hostname) # resolve DNS FIRST, check the ACTUAL destination
ip = ipaddress.ip_address(resolved_ip)
return not (ip.is_private or ip.is_loopback or ip.is_link_local) # reject ANY internal-range addressA more robust check resolves the hostname to its actual IP address before deciding whether the request is safe, then checks whether that resolved IP falls into any private/internal address range — closing the DNS-based bypass, since the check now inspects where the request will genuinely go, not just the string the attacker supplied. Even this needs care around redirect-following (re-validating the destination after every redirect, not just the first URL) and is generally best combined with genuinely restricting what the fetching code's network position can reach in the first place (network-level egress rules), rather than relying on application-level validation alone as the only layer.
The recurring, real-world targets SSRF is used to reach
Cloud metadata endpoints (169.254.169.254 on AWS/GCP/Azure, which can leak real, live cloud credentials with no authentication required from inside the network) are the single most common, most damaging real-world SSRF target — a successful SSRF against a cloud-hosted application frequently escalates directly into full cloud-account compromise via stolen credentials, which is exactly why SSRF is treated as a serious, high-priority vulnerability class rather than a minor inconvenience, despite superficially "just" being about fetching a URL.
Further reading
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why is SSRF dangerous specifically because the SERVER makes the request, not the attacker directly?
2. Why is a simple hostname blocklist (blocking 'localhost', '127.0.0.1') an incomplete fix for SSRF?
3. Why does a more robust SSRF check resolve DNS BEFORE validating the destination, rather than checking the URL string directly?