Web Security Cheatsheet
SSRF and Request Forgery
Use this Web Security reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
What SSRF is
Server-Side Request Forgery is when an application fetches a URL that a user influenced, so the server makes a request on the attacker's behalf. The server usually sits inside a trusted network, so it can reach hosts the attacker cannot.
In the 2025 Top 10, SSRF was folded into A01 Broken Access Control, because it is fundamentally the server acting without checking whether that destination is permitted. It was its own category, A10:2021, in the previous edition.
# Vulnerable: the destination comes from the request @app.post("/api/fetch-preview") def preview(): url = request.json["url"] r = requests.get(url, timeout=5) # server fetches anything return {"title": parse_title(r.text)}
The features that create this surface are ordinary: link previews, webhook registration, PDF and screenshot rendering, avatar-by-URL, importing from a spreadsheet URL, an XML parser that resolves external entities, and any "integrate with your own endpoint" setting.
Why it is high impact
The server's network position is the whole problem. A request originating inside the perimeter reaches things that were never meant to be internet facing.
| Reachable target | Consequence |
|---|---|
| Cloud instance metadata service | Credentials for the instance's role, the highest-severity case |
127.0.0.1 and localhost | Admin panels, debug endpoints, unauthenticated sidecars |
Private ranges (10/8, 172.16/12, 192.168/16) | Internal APIs, databases, dashboards |
| Kubernetes API server or kubelet | Cluster control depending on the service account |
| Container orchestrator sockets | Container escape and host control |
Link-local (169.254/16) | Metadata and other node-local services |
Non-HTTP schemes (file:, gopher:, dict:) | File reads, protocol smuggling |
| Cloud-internal DNS names | Service discovery your app was not supposed to enumerate |
The metadata endpoint case
Every major cloud exposes an instance metadata service on a link-local address that returns credentials for the attached role. A server that fetches an arbitrary URL can be pointed at it, and the credentials come back with no authentication needed.
| Cloud | Metadata address | Mitigation |
|---|---|---|
| AWS | 169.254.169.254 | Enforce IMDSv2, which requires a PUT to get a token plus a header, so a plain GET fails. Set the hop limit to 1. |
| GCP | metadata.google.internal (169.254.169.254) | Requires Metadata-Flavor: Google, so do not let user input set headers |
| Azure | 169.254.169.254/metadata | Requires Metadata: true |
| Alibaba, Oracle, DigitalOcean | 169.254.169.254 | Same pattern, check the provider's token option |
# AWS: require IMDSv2 and stop the response leaving the instance aws ec2 modify-instance-metadata-options \ --instance-id i-0abc \ --http-tokens required \ --http-put-response-hop-limit 1
Requiring a token, and disallowing user-controlled request headers, converts the most severe SSRF outcome into a failed request. Do this even if you also validate URLs, because it is the layer that holds when validation is bypassed.
Why simple validation fails
A denylist of internal addresses parsed from the URL string is the usual first attempt, and it is bypassed reliably. Understand the reasons so you do not ship it.
| Bypass class | Why the check misses |
|---|---|
| DNS rebinding / TOCTOU | You resolve and validate the name, then the HTTP library resolves it again and gets a different address. The check and the use are separate events. |
| Alternate IP encodings | Decimal, octal, hex, and IPv6-mapped forms all denote the same address, and parsers disagree about which are valid |
| Redirects | A public URL responds 302 to an internal one, and the client follows |
| Parser differentials | Your URL parser and the HTTP client's parser disagree about the host, especially with credentials, @, or unusual characters in the authority |
| Attacker-controlled DNS | A public hostname whose A record simply points at a private address |
| Non-HTTP schemes | Blocking http and https targets does nothing about file: or gopher: |
| IPv6 and NAT64 forms | Private space has many representations |
The conclusion is not "validate harder". It is that validation cannot be the only layer, because the correctness depends on components you do not control.
The correct fix: allowlist plus egress control
Layer these. Any one alone has a known failure mode.
1. Allowlist the destination, do not denylist. If the feature is "fetch from our partner's API", the allowed set is a handful of hosts you wrote down. Match the parsed host against an exact set.
from urllib.parse import urlparse ALLOWED_HOSTS = {"api.partner.example", "cdn.partner.example"} def check_url(raw: str) -> str: u = urlparse(raw) if u.scheme not in ("https",): # scheme allowlist too raise ValueError("scheme not allowed") if u.hostname not in ALLOWED_HOSTS: # exact match, never endswith raise ValueError("host not allowed") if u.port not in (None, 443): raise ValueError("port not allowed") return raw
2. Resolve once, validate the address, connect to that address. This closes the
rebinding window. Pin the resolved IP and pass the hostname for TLS and Host.
import ipaddress, socket def resolve_public(host: str) -> str: infos = socket.getaddrinfo(host, None) addrs = {i[4][0] for i in infos} for a in addrs: ip = ipaddress.ip_address(a) if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified): raise ValueError(f"blocked address {a}") return next(iter(addrs)) # connect to THIS address, do not re-resolve
3. Disable redirect following, or re-validate every hop.
r = requests.get(url, allow_redirects=False, timeout=5) # If you must follow, loop manually and run check_url + resolve_public per hop.
4. Enforce egress control at the network layer. This is the layer that holds when application code is wrong, so it matters most. Give the fetching workload its own security group, subnet, or egress NetworkPolicy that can reach only what the feature needs, and block link-local addresses outright.
5. Route outbound fetches through a dedicated proxy in a separate network segment with no credentials and no route to internal services. The proxy becomes the single place the allowlist is enforced, instead of every call site.
6. Do not return the raw response to the user. Return only the parsed fields the feature needs, so a blind SSRF does not become a data channel. Suppress error detail and normalize timing where you can.
Recognizing it in review
grep -rnE "requests\.(get|post)\(.*(url|link|uri)\b" src/ grep -rnE "fetch\((req|request)\.|axios\.(get|post)\((req|request)\." src/ grep -rnE "urlopen\(|HttpClient.*GetAsync\(.*input|curl_exec" src/ grep -rn "allow_redirects=True\|followRedirect" src/ grep -rniE "documentbuilderfactory|xmlreader|etree\.(parse|fromstring)" src/
Checklist:
- Ask first whether the URL needs to be user supplied at all. Often a fixed provider list removes the surface entirely. - Destination hosts, schemes, and ports are exact-match allowlists. - The address is resolved once, validated, and connected to directly. - Redirects are disabled, or every hop is re-validated. - Private, loopback, link-local, reserved, and multicast ranges are rejected in both IPv4 and IPv6. - IMDSv2 (or the provider equivalent) is required, with a hop limit of 1. - Network egress from the fetching workload is restricted, not just the code. - Response bodies and error detail are not echoed back to the caller. - Every XML parser has DTD processing disabled. - Timeouts and response size caps are set, so a fetch cannot be used to exhaust the service.
Further reading: the OWASP SSRF Prevention Cheat Sheet and XXE Prevention Cheat Sheet.