SSRF Defense in Practice: From Principles to WAF and Code-Level Protection
SSRF (Server-Side Request Forgery) is one of the most common critical bugs in enterprise web apps. This guide covers URL allowlists, private-IP filtering, DNS re-validation, HTTP redirect intercept, and Nginx WAF rules.
Scope
SSRF (Server-Side Request Forgery) shows up wherever a web app issues HTTP requests from the server: URL preview/screenshot generation, webhook callbacks, API proxy forwarding, remote image/file fetch, third-party OAuth, and similar flows. Attackers can reach internal services (Redis, MySQL, cloud metadata APIs) or scan ports. This guide covers hardening PHP/Python/Java/Go backends and Nginx-based WAF controls.
Prerequisites
- A Linux server (Ubuntu 22.04 / CentOS 7+ in the examples)
- Nginx installed as reverse proxy or WAF
- root or sudo
- Basic regex and Nginx config knowledge
Mechanism
Root cause: the app fetches a user-supplied URL without enough target validation. Attackers send http://127.0.0.1:6379/, http://169.254.169.254/latest/meta-data/ (cloud metadata), or file:///etc/passwd and skip front-end checks to hit internal resources. Defense: tightly control outbound request targets with layered checks: application validation → DNS re-validation → network-layer blocking.
Procedure
Step 1: URL parse and allowlist checks (application layer)
Baseline defense is an allowlist: only pre-authorized domains or IP ranges. Python example:
import re, socket, ipaddress
from urllib.parse import urlparse
# Allowed domain list
ALLOWED_DOMAINS = [
'api.trusted.com',
'cdn.trusted-cdn.com',
]
# Allowed IP ranges (CIDR)
ALLOWED_IPS = [
ipaddress.ip_network('203.0.113.0/24'),
]
def validate_url(user_url):
"""Validate whether the request URL is safe"""
try:
parsed = urlparse(user_url)
except Exception:
return False, "URL parse failed"
# 1. Scheme check: http/https only
if parsed.scheme not in ('http', 'https'):
return False, "Unsupported scheme: " + parsed.scheme
# 2. Domain allowlist
hostname = parsed.hostname.lower()
if hostname in ALLOWED_DOMAINS:
return True, "Domain on allowlist"
# 3. Block private addresses
try:
addr = ipaddress.ip_address(hostname)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False, "Private address blocked: " + hostname
if hostname == '169.254.169.254':
return False, "Cloud metadata address blocked"
except ValueError:
pass
# 4. Re-validate after DNS resolution
try:
ip = socket.gethostbyname(hostname)
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False, "DNS resolved to private address: " + ip
for net in ALLOWED_IPS:
if addr in net:
return True, "IP in allowed range"
except socket.gaierror:
return False, "DNS resolution failed"
return True, "Validation passed"
# Test cases
test_urls = [
'http://127.0.0.1:6379/',
'http://169.254.169.254/latest/meta-data/',
'http://10.0.0.1/admin',
'http://api.trusted.com/data',
'http://evil.com/ssrf',
]
for url in test_urls:
ok, reason = validate_url(url)
print(f"{'OK' if ok else 'X'} URL: {url} - {reason}")
Step 2: 302 redirect hijack protection
Attackers often bounce through a 302 from an allowed domain to an internal address. Defense:
import requests
def safe_fetch(url, max_redirects=3, timeout=5):
"""Safe HTTP request; validate each redirect hop"""
ok, reason = validate_url(url)
if not ok:
raise ValueError("Original URL rejected: " + reason)
current_url = url
for i in range(max_redirects):
r = requests.get(current_url, allow_redirects=False,
timeout=timeout, headers={'User-Agent': 'SafeFetch/1.0'})
if r.status_code in (301, 302, 303, 307, 308):
next_url = r.headers.get('Location', '')
if not next_url:
break
ok, reason = validate_url(next_url)
if not ok:
raise ValueError("Unsafe redirect target: " + next_url + " - " + reason)
current_url = next_url
else:
return r.content
r = requests.get(current_url, timeout=timeout,
headers={'User-Agent': 'SafeFetch/1.0'})
return r.content
Step 3: Nginx WAF intercept
Second line of defense at the Nginx reverse proxy:
# /etc/nginx/conf.d/ssrf_waf.conf
# Internal IP map
map $http_host $is_internal_ip {
default 0;
"~^127\." 1;
"~^10\." 1;
"~^172\.(1[6-9]|2[0-9]|3[01])\." 1;
"~^192\.168\." 1;
"~^0\." 1;
"~^169\.254\." 1;
"~^::1$" 1;
"~^fc00:" 1;
"~^fe80:" 1;
}
server {
listen 443 ssl;
server_name your-api.com;
if ($is_internal_ip) {
return 403;
}
if ($arg_url ~* "^file:") {
return 403;
}
location /proxy/ {
limit_except GET HEAD { deny all; }
proxy_connect_timeout 5s;
proxy_read_timeout 5s;
client_max_body_size 1k;
proxy_pass $arg_url;
}
}
Step 4: Java/Golang reference implementations
Java — custom URLConnection interceptor:
public class SSRFRequestFactory extends SimpleClientHttpRequestFactory {
@Override
protected void prepareConnection(HttpURLConnection conn, String method) {
String host = conn.getURL().getHost();
try {
InetAddress addr = InetAddress.getByName(host);
if (addr.isSiteLocalAddress() || addr.isLoopbackAddress()
|| addr.isLinkLocalAddress()) {
throw new SecurityException("SSRF blocked: " + host);
}
} catch (UnknownHostException e) {
throw new SecurityException("DNS resolve failed");
}
super.prepareConnection(conn, method);
}
}
Golang — custom Transport:
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, _ := net.SplitHostPort(addr)
ip := net.ParseIP(host)
if ip != nil {
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return nil, fmt.Errorf("SSRF blocked: %s", addr)
}
}
return net.Dialer{}.DialContext(ctx, network, addr)
},
}
client := &http.Client{Transport: transport, Timeout: 5 * time.Second}
Verification
# 1. Private addresses should be rejected
curl -v "https://your-api.com/proxy/?url=http://127.0.0.1:6379/"
# Expected: 403
# 2. Cloud metadata addresses should be rejected
curl -v "https://your-api.com/proxy/?url=http://169.254.169.254/latest/meta-data/"
# Expected: 403
# 3. Allowlisted domains should pass
curl -v "https://your-api.com/proxy/?url=http://api.trusted.com/data"
# Expected: 200 OK
# 4. File scheme should be rejected
curl -v "https://your-api.com/proxy/?url=file:///etc/passwd"
# Expected: 403
FAQ
Q1: Why is a URL allowlist not enough?
Attackers exploit URL-parser mismatches. Examples: http://allowed-domain.com@127.0.0.1:6379 (some libraries ignore the part before @), http://127.0.0.1:6379#@allowed-domain.com (the fragment after # is ignored), or DNS rebinding (first lookup hits a legit IP, second hits an internal IP). You still need DNS re-validation, hop-by-hop redirect checks, and network-layer blocking.
Q2: How do I handle large numbers of dynamic URLs (e.g. URL preview)?
Use proxy isolation: run the fetcher in a sandbox (Docker + strict network policy) with outbound internet only and no internal reachability. Optionally render in Puppeteer/Playwright inside an isolated network namespace. Still apply a domain denylist and content security policy at the app layer.
Summary
SSRF defense is layered: app-layer URL allowlist + private-IP block + DNS re-validation, Nginx WAF intercept, and infra network isolation. No single control stops 100% of SSRF; stacked controls cut risk. Periodically scan with SSRFmap.