← News
/PLAYBOOK · 2026-07-30

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

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.