SSRF (Server-Side Request Forgery) happens when an application fetches a resource from a user-controlled URL without properly validating the destination. Rather than attack the server head-on, the attacker makes the server attack on their behalf. Because the request originates inside the infrastructure, it often crosses firewalls, reaches internal services that should never be exposed, and inherits the trust the internal network places in itself.

In cloud environments SSRF gets especially dangerous. It is the classic gateway to stealing temporary credentials through the metadata endpoint.

Where SSRF is born

Any feature that takes a URL and fetches it on the backend is a candidate:

  • “Import your profile photo from a URL”
  • Webhooks and integrations (“send events to this URL”)
  • PDF/screenshot generators that render a page
  • URL validators, link previews, image proxies
  • XML parsers with external entities (XXE turning into SSRF)
# Vulnerable endpoint: fetches whatever URL the user sends, unrestricted
@app.post("/import")
def import_url():
    url = request.json["url"]
    resp = requests.get(url, timeout=5)   # <- SSRF
    return resp.content

The most valuable target: cloud metadata

Cloud providers expose a metadata endpoint on a link-local IP accessible only from inside the instance. In many environments it hands out temporary credentials for the role attached to the machine:

http://169.254.169.254/latest/meta-data/iam/security-credentials/

If the server makes the request the attacker dictates, they can ask for that address and receive back AccessKeyId, SecretAccessKey and Token, credentials valid for talking to the cloud API as if they were the application itself. That is the escalation that turns a “fetch this URL” into a full account compromise.

Specific mitigation: enforce IMDSv2 (a token-based flow with PUT plus header and a hop TTL of 1). It breaks most naive SSRFs, which can only manage a simple GET. Even so, treat it as a layer, not a single solution.

Other common internal targets: unauthenticated databases and caches (http://127.0.0.1:6379 for Redis), internal admin panels, orchestration APIs (Kubernetes, Consul) and good old localhost.

Blind SSRF

The response does not always come back to the attacker. In blind SSRF, the application makes the request but does not return the body. It is still exploitable:

  • Side-channel exfiltration: differences in response time or status code reveal whether an internal port is open, which lets you scan the network from inside.
  • Out-of-band interactions: you point at a domain you control (the attacker’s DNS/HTTP server) and watch the call arrive. That confirms the SSRF and can leak data through the subdomain.

Why naive filters fail — the bypasses

The instinctive reaction is to block localhost and 169.254.169.254. That is almost never enough. Here are the classic bypasses:

Alternative IP representations:

http://2130706433/         # 127.0.0.1 in decimal
http://0x7f000001/         # 127.0.0.1 in hexadecimal
http://0177.0.0.1/         # octal
http://127.1/              # short form
http://[::1]/              # IPv6 loopback
http://[::ffff:169.254.169.254]/   # IPv4 mapped into IPv6

URL and parsing tricks:

http://169.254.169.254.nip.io/     # DNS that resolves to the internal IP
http://attacker.com@169.254.169.254/   # confuses validators that look only at the textual "host"
http://meta​data/             # unicode/encoding characters

Redirect: the attacker points at http://attacker-site.com/r, which answers 302 Location: http://169.254.169.254/.... If the application follows redirects and only validated the initial URL, the filter is gone.

DNS rebinding: the attacker’s domain resolves to a public IP on the first lookup, passing validation, and to 169.254.169.254 on the second, at the moment of the real fetch. The vulnerability lives in the window between validating and fetching. This is why validating the URL string is not enough: what counts is the destination IP at the instant of connection.

The correct defense architecture

SSRF is not solved with a regex. The defense is layered, from code to network.

1. Allowlist, never blocklist

Define the permitted destinations explicitly. If the feature only needs to talk to api.partner.com, allow that host and nothing else. Everything else is denied by default.

ALLOWED_HOSTS = {"api.partner.com", "cdn.partner.com"}

def safe_fetch(url):
    parts = urlparse(url)
    if parts.scheme not in ("https",):       # https only
        raise ValueError("scheme not allowed")
    if parts.hostname not in ALLOWED_HOSTS:   # host allowlist
        raise ValueError("host not allowed")
    # Resolve and validate the IP BEFORE connecting (see step 2)
    ...

2. Resolve DNS and validate the IP — and connect to that IP

To defeat DNS rebinding and redirects, resolve the name, reject private and reserved IPs, and force the connection to use exactly the IP you validated, with no second resolution:

import ipaddress, socket

BLOCKED_NETS = [
    ipaddress.ip_network(n) for n in [
        "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
        "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10",
    ]
]

def ip_allowed(host):
    for fam, _, _, _, sockaddr in socket.getaddrinfo(host, None):
        ip = ipaddress.ip_address(sockaddr[0])
        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
            return False
        if any(ip in net for net in BLOCKED_NETS):
            return False
    return True

Validate all returned IPs, since a host can have several A/AAAA records, and revalidate after each redirect. Simpler still: disable redirect following in the HTTP client.

3. Network segmentation (the layer that saves you)

Treat the code as fallible and isolate by network. The workload that performs external fetches should have no route to the metadata endpoint, to the database subnet, or to internal panels. Egress filtering and restrictive security groups turn a successful SSRF into a dead end.

4. Cloud-specific hardening

  • IMDSv2 mandatory with hop limit = 1.
  • Least-privilege roles: if credentials leak, the blast radius is contained.
  • Block, by network route, the application’s access to 169.254.169.254 when it doesn’t need metadata.

How we test SSRF

  1. Catalog every input that becomes a server-side request (URLs, webhooks, parsers, importers).
  2. Point at a controlled out-of-band listener and confirm the call arrives (proof of SSRF, including blind).
  3. Test the bypass battery: decimal/hex/octal IP, IPv6, @, nip.io, 302 redirect, DNS rebinding.
  4. Try to reach 169.254.169.254, 127.0.0.1 on internal ports, and orchestration services.
  5. Measure real impact: reading metadata, internal scanning, access to unauthenticated services.

Mitigation checklist

  • Allowlist of host and scheme (https) — deny by default.
  • Resolve DNS, reject private/reserved IPs and connect to the validated IP.
  • Do not follow redirects (or revalidate the destination on every hop).
  • Network segmentation / egress filtering isolating the workload from metadata and the internal network.
  • IMDSv2 mandatory, least-privilege roles.
  • Disable dangerous schemes (file://, gopher://, dict://) in the HTTP client.
  • Short timeouts and a generic response (don’t return a body/error that serves as an oracle).

SSRF is the vulnerability that best illustrates why “validating the input” and “validating the destination” are different problems. A defense that works never trusts the URL string. It controls which IP, on which network, the server is allowed to connect to.