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 targetConsequence
Cloud instance metadata serviceCredentials for the instance's role, the highest-severity case
127.0.0.1 and localhostAdmin panels, debug endpoints, unauthenticated sidecars
Private ranges (10/8, 172.16/12, 192.168/16)Internal APIs, databases, dashboards
Kubernetes API server or kubeletCluster control depending on the service account
Container orchestrator socketsContainer 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 namesService 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.

CloudMetadata addressMitigation
AWS169.254.169.254Enforce IMDSv2, which requires a PUT to get a token plus a header, so a plain GET fails. Set the hop limit to 1.
GCPmetadata.google.internal (169.254.169.254)Requires Metadata-Flavor: Google, so do not let user input set headers
Azure169.254.169.254/metadataRequires Metadata: true
Alibaba, Oracle, DigitalOcean169.254.169.254Same 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 classWhy the check misses
DNS rebinding / TOCTOUYou 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 encodingsDecimal, octal, hex, and IPv6-mapped forms all denote the same address, and parsers disagree about which are valid
RedirectsA public URL responds 302 to an internal one, and the client follows
Parser differentialsYour URL parser and the HTTP client's parser disagree about the host, especially with credentials, @, or unusual characters in the authority
Attacker-controlled DNSA public hostname whose A record simply points at a private address
Non-HTTP schemesBlocking http and https targets does nothing about file: or gopher:
IPv6 and NAT64 formsPrivate 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.