Back to Cheat Sheets

🛰️ Stop Server-Side Request Forgery

OWASP Top 10 Proactive Controls (2024) - C10

CORE CONTROL

📋 What Is This Control?

Stop Server-Side Request Forgery (SSRF) is the control that makes sure when your server fetches a URL, it can only ever reach destinations you intended - never an internal service, a cloud metadata endpoint, or a local file. SSRF happens when an attacker supplies or influences a URL the application then requests on its behalf, turning your trusted server into a proxy that speaks from inside your network. The single most important idea: a URL is not safe just because its hostname looked safe - validate the resolved IP, pin it for the connection, and confine the request at the network layer.

C10 Proactive Control
Allow-list Focus Area
OWASP 2024 Proactive Controls

Taxonomy note: SSRF was its own category (A10) in the 2021 web Top 10 and is folded into Broken Access Control in 2025 - but stopping it remains a distinct engineering discipline, so it stands alone here as C10.

🎯 What It Prevents

  • Cloud Metadata Theft: Reaching 169.254.169.254 to steal instance credentials
  • Internal Service Access: Admin panels, DBs, queues never meant to face the internet
  • Internal Recon / Port Scanning: Mapping live hosts via timing & error differences
  • Local File Disclosure: file:///etc/passwd-style reads
  • Protocol Smuggling: gopher:// / dict:// crafting raw requests to Redis/SMTP
  • DNS-Rebinding Bypass: A safe hostname re-pointed to a private IP after the check
  • Redirect Bypass: A validated URL that 302s to a metadata/internal address

🧭 How It Works

1. Parse the URL; allow-list scheme + host + port (deny by default)
↓
2. Resolve DNS and validate the RESOLVED IP (block private/link-local)
↓
3. Pin that exact IP for the connection (defeat DNS rebinding)
↓
4. Disable file/gopher/dict; do not follow redirects
↓
5. Confine at network egress + enforce IMDSv2; bound the response

❌ Without This Control

# Flask: fetches ANY user-supplied URL verbatim import requests from flask import Flask, request, Response app = Flask(__name__) @app.route('/fetch') def fetch(): url = request.args.get('url') # attacker-controlled r = requests.get(url) # file://, 169.254.169.254, 10.0.0.5 all reachable return Response(r.content, r.status_code) # raw internal body reflected back # Problems: # - no scheme check -> file:// and gopher:// allowed # - no IP validation -> loopback / metadata / private ranges reachable # - redirects auto-followed; a 302 to 169.254.169.254 is honored # - client re-resolves DNS -> rebinding changes the answer after the check # - raw upstream response reflected straight to the caller # Attacker: /fetch?url=http://169.254.169.254/latest/meta-data/iam/...

✅ Applying the Control

# Flask: allow-list scheme, validate + pin the resolved IP, no redirects, bound size import ipaddress, socket, requests from urllib.parse import urlparse from flask import Flask, request, jsonify app = Flask(__name__) ALLOWED_SCHEMES = {'https'} MAX_BYTES, TIMEOUT = 1_000_000, 5 def is_blocked_ip(ip_str): ip = ipaddress.ip_address(ip_str) return (ip.is_private or ip.is_loopback or ip.is_link_local # 169.254.169.254 or ip.is_reserved or ip.is_multicast or ip.is_unspecified) # IPv4 & IPv6 def resolve_safe_ip(host): ips = {i[4][0] for i in socket.getaddrinfo(host, None)} for ip in ips: # EVERY answer must be safe if is_blocked_ip(ip): raise ValueError('blocked address') return next(iter(ips)) # pin this exact IP for the connection @app.route('/fetch') def fetch(): p = urlparse(request.args.get('url', '')) if p.scheme.lower() not in ALLOWED_SCHEMES or not p.hostname: return jsonify(error='destination not allowed'), 400 # no file://, gopher:// try: ip = resolve_safe_ip(p.hostname) # validate resolved IP except ValueError: return jsonify(error='destination not allowed'), 400 pinned = f'https://{ip}:{p.port or 443}{p.path or "/"}' r = requests.get(pinned, headers={'Host': p.hostname}, timeout=TIMEOUT, allow_redirects=False, stream=True) # refuse redirects body = next(r.iter_content(MAX_BYTES), b'') # size-bounded return jsonify(status=r.status_code, length=len(body)) # never reflect raw body # Plus network egress filtering + IMDSv2 (session token, hop-limit=1) as defense in depth

✓ Implementation Checklist

  • Inventory every feature where the server fetches a user-influenced URL
  • Validate each fetch against an allow-list of schemes, hosts, and ports (deny by default)
  • Resolve DNS and validate the resolved IP; reject private/loopback/link-local/reserved (IPv4 & IPv6)
  • Re-resolve and pin the IP at connection time to defeat DNS rebinding
  • Disable non-HTTP schemes (file, gopher, dict, ftp)
  • Block redirects, or re-validate every hop as strictly as the first
  • Enforce network egress filtering so the app cannot reach internal ranges or the metadata IP
  • Enforce IMDSv2 (session tokens) with a restrictive hop limit on cloud hosts
  • Size-limit and time-limit upstream responses; never reflect them raw
  • Run the fetching service with least-privilege IAM and strict timeouts
  • Prefer a single centralized, vetted SSRF-safe HTTP client over raw requests.get(url)

🔍 Tools & Techniques

Libraries & Platform:

SSRF-safe HTTP client IMDSv2 Egress firewall / NACL Metadata proxy Private DNS resolver

Testing:

Burp Suite (Collaborator) OWASP ZAP SSRFmap Semgrep CodeQL

How to Verify:

  • Try file://, gopher://, dict:// - all must be rejected
  • Point a controlled hostname at 127.0.0.1 / 169.254.169.254 and confirm it is blocked
  • Test IP-encoding tricks: 127.1, 2130706433, 0x7f000001, [::1]
  • Set up a redirect to a private IP and confirm it is not followed
  • Exercise DNS rebinding (answer changes between check and connect) and confirm the pin holds

🌍 Why It Matters

  • Cloud metadata credential theft: The most damaging SSRF class - a user-influenced fetch is steered to the link-local metadata address 169.254.169.254 and, where credentials are exposed without a session token, the instance role's temporary keys are retrieved and used against the cloud account. Session-token metadata services (IMDSv2) and hop limits exist precisely because of this incident class.
  • Reaching internal-only services: SSRF requests admin consoles, databases, caches, and orchestration APIs never exposed to the internet - because the request originates from a trusted internal source, "internal is safe" assumptions do not stop it.
  • Internal recon & port scanning: Even without reading a response, timing and error differences let an attacker enumerate live hosts and open ports for a later attack.
  • Local file & protocol abuse: Where the client honors extra schemes, file:// reads local files and gopher:///dict:// smuggle crafted bytes to line-based internal services.

Described as documented incident classes, not tied to a specific vendor or CVE. The reliable control is IP-level validation after resolution plus network egress filtering - defense in depth, not a regex.

📌 Quick Tips

  • DO NOT trust a URL because its hostname looked safe
  • DO NOT rely on a block-list of 127.0.0.1/localhost - it is endlessly bypassable
  • DO NOT follow redirects on user-supplied fetches
  • DO allow-list scheme + host + port, deny everything else
  • DO validate the resolved IP and pin it for the connection
  • DO block private/loopback/link-local ranges at the network egress too
  • DO enforce IMDSv2 and size-limit every upstream response