Server-side request forgery is one of those bugs that looks trivial in a bug report — “the app fetches a URL I gave it” — and turns into a full cloud account compromise by the time the write-up is finished. It doesn’t need memory corruption, it doesn’t need a clever payload encoding, and it doesn’t even need the attacker to see a response. It needs exactly one thing: a server that will make an outbound request on your behalf to a destination you control.

That single primitive is enough to steal cloud credentials, reach databases that were never meant to see the internet, and pivot from a “harmless” image-preview feature into internal infrastructure. If you run any service that fetches a webhook URL, renders a link preview, converts a remote PDF, or proxies an image, you have an SSRF surface. This article walks through how the attack class works, why cloud metadata endpoints made it so much worse, and the defenses that hold up against the tricks pentesters (and attackers) actually use to bypass naive filters.

The Core Mechanism

SSRF happens whenever server-side code accepts a URL, hostname, or IP address from user input and then makes a request to it. The vulnerable code trusts the destination the same way it trusts the request body — which is the mistake, because the destination is effectively part of your internal network’s trust boundary.

// VULNERABLE: fetches whatever URL the client supplies
app.post("/api/preview", async (req, res) => {
  const { url } = req.body;
  const response = await fetch(url); // no validation at all
  const html = await response.text();
  res.json({ preview: html.slice(0, 500) });
});

An attacker doesn’t need url to point at a public site. They can point it at http://169.254.169.254/latest/meta-data/iam/security-credentials/, at http://localhost:6379 to speak the Redis protocol, or at http://10.0.4.12:9200/_cat/indices to enumerate an internal Elasticsearch cluster. The server has network access those requests need; the attacker doesn’t.

Blind vs. Non-Blind SSRF

The distinction matters for both attackers and defenders:

  • Non-blind SSRF returns the response body (or enough of it) to the attacker, as in the preview example above. This is the easy case — data exfiltration is immediate.
  • Blind SSRF only reveals that a request happened, via timing differences, HTTP status codes, or side effects (an email sent, a webhook retried, a log line written). Exploitation is slower but still dangerous: an attacker can use response time to fingerprint open ports (port scanning through the vulnerable server), or trigger state changes on internal services with no read-back needed at all — think an internal admin API that accepts unauthenticated POST requests to delete a resource.

Don’t dismiss blind SSRF as low severity just because there’s no direct data leak. A blind SSRF against an internal Kubernetes API server or a cloud metadata endpoint is still a critical finding.

The Cloud Metadata Endpoint Problem

Every major cloud provider exposes an instance metadata service (IMDS) at a link-local address, most commonly 169.254.169.254. It answers unauthenticated HTTP requests from the instance itself, and historically that included temporary IAM credentials for whatever role the instance was launched with.

# Classic IMDSv1 request - no token required
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/
curl http://169.254.169.254/latest/meta-data/iam/security-credentials/my-instance-role

If your application’s compute has an IAM role attached and an SSRF bug lets an attacker reach that URL through your server, they get the role’s access key, secret key, and session token — often with permissions to read S3 buckets, write to queues, or worse. This exact chain (public-facing app, SSRF bug, IMDS credential theft) has been behind some of the most damaging breaches in the last decade, and it’s why AWS introduced IMDSv2.

IMDSv2 requires a session-oriented token, fetched via a PUT request with a custom header, before any metadata GET will succeed:

# IMDSv2: a PUT request establishes a short-lived token first
TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")

curl -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/my-instance-role

Why does this stop most SSRF chains? Because the classic SSRF primitive is a simple GET (or occasionally a redirect-following fetch) — the attacker controls the URL, not the HTTP method or arbitrary custom headers. A vulnerable app almost never lets the attacker inject a PUT with a custom header as a first step, so IMDSv2’s token requirement quietly defeats the vast majority of real-world SSRF-to-credential-theft chains, even though it wasn’t designed as an SSRF fix specifically. Azure and GCP have similar metadata endpoints (169.254.169.254 on Azure, plus a required Metadata: true header; GCP requires Metadata-Flavor: Google), and the same principle applies: a mandatory custom header on the request raises the bar considerably.

If you run instances on any cloud provider, enforcing the token/header-required mode is close to free and should be a default, not an opt-in.

# Enforce IMDSv2 on an existing EC2 instance (AWS CLI)
aws ec2 modify-instance-metadata-options \
  --instance-id i-0123456789abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

That --http-put-response-hop-limit 1 matters too: it stops a request from surviving one extra network hop, which closes off a specific bypass where an SSRF victim proxies the metadata request through yet another internal service.

DNS Rebinding: Defeating Naive Hostname Filters

A common (and insufficient) SSRF defense is to resolve the hostname once, check that the IP isn’t internal, then make the request using the hostname again. DNS rebinding exploits the gap between those two steps.

  1. The attacker controls a domain, say evil.example, with a very low DNS TTL.
  2. When your validation code resolves evil.example, the attacker’s DNS server answers with a public, harmless IP (say 93.184.216.34). Validation passes.
  3. By the time the actual HTTP client resolves evil.example again to make the request, the attacker’s DNS server now answers with 169.254.169.254 or 127.0.0.1.

The check and the use are two different DNS lookups, and the attacker fully controls both answers. The fix is to resolve once, validate that resolved IP, and then connect to that literal IP — never re-resolve the hostname between check and use.

import ipaddress
import socket
import requests

BLOCKED_NETWORKS = [
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),  # link-local, includes cloud IMDS
    ipaddress.ip_network("::1/128"),
    ipaddress.ip_network("fc00::/7"),
]

def resolve_and_validate(hostname: str) -> str:
    resolved_ip = socket.gethostbyname(hostname)  # resolve exactly once
    ip = ipaddress.ip_address(resolved_ip)
    if any(ip in net for net in BLOCKED_NETWORKS):
        raise ValueError(f"Destination {resolved_ip} is not allowed")
    return resolved_ip

def safe_fetch(url: str, allowed_hosts: set[str]):
    from urllib.parse import urlparse
    parsed = urlparse(url)
    if parsed.scheme not in ("https", "http"):
        raise ValueError("Only http/https schemes are allowed")
    if parsed.hostname not in allowed_hosts:
        raise ValueError(f"Host {parsed.hostname} is not on the allowlist")

    ip = resolve_and_validate(parsed.hostname)
    # Connect to the pinned IP, but keep the original Host header for TLS/SNI
    session = requests.Session()
    session.mount(f"https://{parsed.hostname}", requests.adapters.HTTPAdapter())
    return requests.get(url, headers={"Host": parsed.hostname}, timeout=5,
                         allow_redirects=False)  # never auto-follow redirects

Two details in that snippet matter beyond DNS pinning: allow_redirects=False, and an explicit allowlist. A server-side HTTP client that happily follows redirects can be handed a URL that starts on an allowed public host and then 302s to http://169.254.169.254/. If your use case genuinely needs to follow redirects, re-validate the destination after every hop, not just the first.

Allowlists Beat Denylists, Every Time

Denylisting specific ranges (127.0.0.1, 10.0.0.0/8, cloud metadata IPs) feels thorough until you account for the encodings a URL parser will accept as equivalent to those addresses:

Denylist targetBypass techniqueWhy it works
127.0.0.1http://0.0.0.0, http://[::1], http://2130706433 (decimal), http://0177.0.0.1 (octal)Many URL/IP parsers normalize these to loopback, but a naive string-match denylist checks the literal text, not the resolved value
169.254.169.254http://169.254.169.254.nip.io, or a redirect chain landing thereDNS-based wildcarding services resolve arbitrary subdomains to any IP you embed in the name
Internal hostnameshttp://internal-api vs http://internal-api. (trailing dot) or mixed caseDenylists that string-match exact hostnames miss trivially equivalent forms
Any blocked IPDifferent libraries in your stack (validation vs. the actual HTTP client) parse the URL differentlyA discrepancy between the parser used to validate and the parser used to connect is itself an SSRF bypass class

The last row is the one that keeps showing up in real-world reports: validation logic and request logic parsing the same URL string differently. If you validate with one library and fetch with another, any disagreement between the two on what a URL “means” is an exploitable gap.

An allowlist of exact, known-good destinations — specific hostnames, specific IPs, specific ports — sidesteps almost all of this, because you’re not trying to enumerate every way to spell something bad; you’re checking membership in a small, explicit, known-good set.

ApproachEffort to maintainBypass resistanceBest for
Denylist (block known-bad ranges)Low upfront, high ongoingWeak — new encodings and rebinding keep appearingNever, as the sole control
Allowlist (explicit known-good hosts)Higher upfront (enumerate legitimate destinations)StrongWebhooks to a fixed set of partners, internal service-to-service calls
Network-level egress filteringMedium, one-time infra workStrong, defense-in-depthEvery deployment, regardless of app-level controls

Egress Filtering as a Safety Net

Application-level validation should never be your only line of defense — treat it as one layer, with network policy as the backstop for when a bypass slips through code review.

# nftables: only allow the app's outbound traffic to specific destinations
nft add table inet filter
nft add chain inet filter output { type filter hook output priority 0 \; }

# Allow DNS and traffic to the approved partner API range
nft add rule inet filter output udp dport 53 accept
nft add rule inet filter output ip daddr 203.0.113.0/24 tcp dport 443 accept

# Explicitly drop link-local (blocks cloud metadata) and RFC1918 ranges
nft add rule inet filter output ip daddr 169.254.0.0/16 drop
nft add rule inet filter output ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } drop

# Default deny for everything else the app tries to reach
nft add rule inet filter output drop

On a container-based deployment, the same idea applies at the Docker network level: put the service that fetches user-supplied URLs on its own network with no route to your database or internal admin services, and let it reach only the public internet (or nothing at all, if an explicit allowlist of destinations is feasible). On Kubernetes, a NetworkPolicy that denies egress to the pod CIDR and to 169.254.169.254 for any workload that doesn’t need instance metadata closes the same gap.

A Practical Testing Checklist

When you’re assessing an application (yours, or as an authorized pentester) for SSRF, work through:

  • Every feature that takes a URL as input: webhooks, avatar/image import, link unfurling, PDF/HTML-to-image rendering, “import from URL,” RSS/Atom feed readers, SSO metadata URL fields.
  • Whether validation resolves the hostname once and reuses the resolved IP, or re-resolves later (rebinding risk).
  • Whether the HTTP client follows redirects, and if so, whether each hop is re-validated.
  • Whether alternate IP encodings (decimal, octal, IPv6-mapped IPv4 like ::ffff:169.254.169.254) are rejected.
  • Whether the cloud metadata service requires a token/header (IMDSv2 or equivalent) — and if the app runs on a legacy instance still allowing IMDSv1.
  • Whether outbound network policy would catch a bypass even if the application-level check failed.

Key Takeaways

  • SSRF turns a “fetch this URL” feature into a foothold on your internal network; it needs no memory corruption, just a server willing to make requests on the attacker’s behalf.
  • Blind SSRF (no response body returned) is still critical when it can reach unauthenticated internal APIs or trigger state-changing side effects.
  • Enforce IMDSv2 (or the equivalent token/header requirement on Azure and GCP) on every instance — it defeats the majority of real-world SSRF-to-credential-theft chains for close to zero cost.
  • Resolve hostnames exactly once, validate the resolved IP, and connect to that literal IP to prevent DNS rebinding; never re-resolve between check and use.
  • Prefer allowlists of specific, known-good destinations over denylists of “bad” ranges — denylists lose to alternate IP encodings and parser mismatches.
  • Never auto-follow redirects in a server-side HTTP client without re-validating the destination on every hop.
  • Treat network-level egress filtering (nftables, Docker networks, Kubernetes NetworkPolicy) as a mandatory backstop, not an optional extra, for any service that fetches user-controlled URLs.