SSRF Prevention: Server-Side Request Forgery Defense Guide

Type: Software Reference Confidence: 0.95 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

SSRF Threat/Fix Checklist

#VulnerabilityRiskVulnerable CodeSecure Code
1Internal network accessCritical -- access admin panels, databases, internal APIsrequests.get(user_url)Allowlist hosts + validate resolved IP is not private
2Cloud metadata theftCritical -- IAM credential exfiltration (Capital One breach)fetch(url) reaching 169.254.169.254Block link-local ranges + enforce IMDSv2 (PUT + token)
3DNS rebindingHigh -- IP changes between validation and requestValidate hostname, then fetch (separate DNS lookups)Pin DNS: resolve once, validate IP, fetch by IP
4Protocol smugglingHigh -- file://, gopher://, dict:// read local files or interact with servicesurllib.urlopen(user_url) with no scheme checkAllowlist only http:// and https:// schemes
5Open redirect bypassHigh -- redirect from allowed domain to internal targetAllow redirects to pre-validated domainDisable redirects or re-validate after each hop
6IP encoding bypassMedium -- decimal, octal, hex IP representations bypass blocklistsBlock 127.0.0.1 string onlyResolve to IP, then check against private ranges
7URL parser confusionMedium -- inconsistent parsing between validator and HTTP clientValidate URL with one parser, fetch with anotherUse same URL parsing library for validation and request
8IPv6 bypassMedium -- ::1, [::], ::ffff:127.0.0.1 bypass IPv4 blocklistsBlock only IPv4 loopbackCheck both IPv4 and IPv6 private/reserved ranges
9Blind SSRFMedium -- no response but side-channel timing/errors leak infoFetch user URL, discard response (side effects remain)Same validation as full SSRF -- response visibility is irrelevant
10TOCTOU race conditionMedium -- DNS changes between check and useValidate, sleep/delay, then fetchResolve once, connect directly to resolved IP with Host header

Decision Tree

START: Does the application need to fetch user-supplied URLs?
├── Can you restrict to a fixed set of allowed hosts?
│   ├── YES → Use strict allowlist of hostnames + validate resolved IP
│   └── NO ↓
├── Does the app run in a cloud environment (AWS/GCP/Azure)?
│   ├── YES → Block metadata IPs at network layer + enforce IMDSv2/equivalent + app-layer validation
│   └── NO ↓
├── Does the URL come from user input or external data?
│   ├── YES → Parse URL, validate scheme (http/https only), resolve DNS,
│   │          check resolved IP against private ranges, disable redirects
│   └── NO ↓
├── Is the URL from a trusted internal configuration?
│   ├── YES → Still validate at network layer (defense in depth)
│   └── NO ↓
└── DEFAULT → Deny outbound requests by default, allowlist specific destinations

Step-by-Step Guide

1. Validate URL scheme and parse safely

Reject any URL that does not use http or https. Use a well-tested URL parser -- never construct URLs via string concatenation. [src1]

from urllib.parse import urlparse

def validate_url_scheme(url: str) -> bool:
    parsed = urlparse(url)
    return parsed.scheme in ('http', 'https') and parsed.hostname is not None

Verify: Test with file:///etc/passwd, gopher://internal:25/, dict://localhost:6379/ -- all should be rejected.

2. Resolve DNS and validate the IP address

After parsing the URL, resolve the hostname to an IP address and check that it is not in any private, loopback, or link-local range. [src1]

import ipaddress, socket

def is_safe_ip(ip_str: str) -> bool:
    ip = ipaddress.ip_address(ip_str)
    return ip.is_global and not ip.is_reserved and not ip.is_loopback \
           and not ip.is_link_local and not ip.is_private

def resolve_and_validate(hostname: str) -> str:
    results = socket.getaddrinfo(hostname, None)
    for family, _, _, _, sockaddr in results:
        if is_safe_ip(sockaddr[0]):
            return sockaddr[0]
    raise ValueError(f"Hostname {hostname} resolves to blocked IP range")

Verify: Test with localhost, 169.254.169.254, 10.0.0.1, [::1] -- all should raise ValueError.

3. Disable HTTP redirects and connect by resolved IP

Make the request directly to the resolved IP with the original Host header. Disable automatic redirect following. [src2]

import requests

def safe_fetch(url: str, timeout: int = 10) -> requests.Response:
    parsed = urlparse(url)
    if not validate_url_scheme(url):
        raise ValueError(f"Blocked scheme: {parsed.scheme}")
    safe_ip = resolve_and_validate(parsed.hostname)
    return requests.get(url, timeout=timeout, allow_redirects=False)

Verify: Test with a URL that 302-redirects to http://169.254.169.254/ -- should return the redirect without following.

4. Enforce cloud metadata protections

For AWS, enforce IMDSv2 which requires a PUT request to obtain a session token. Block the metadata IP at the network layer. [src5]

# Require IMDSv2 on all EC2 instances
aws ec2 modify-instance-metadata-options \
  --instance-id i-1234567890abcdef0 \
  --http-tokens required \
  --http-put-response-hop-limit 1

# Block metadata IP at instance level
iptables -A OUTPUT -d 169.254.169.254 -j DROP

Verify: curl -s http://169.254.169.254/latest/meta-data/ should fail or time out.

5. Implement network-layer controls

Apply firewall rules and network policies as defense in depth. [src4]

# Kubernetes NetworkPolicy: deny egress to private ranges
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-internal-egress
spec:
  podSelector:
    matchLabels:
      app: web-service
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 127.0.0.0/8]

Verify: From the pod, curl http://169.254.169.254/ and curl http://10.0.0.1/ should time out.

Code Examples

Python: URL Validation with DNS Pinning

# Input:  User-supplied URL string
# Output: HTTP response or ValueError if URL targets blocked IP

import ipaddress, socket, requests
from urllib.parse import urlparse

BLOCKED_RANGES = [
    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('127.0.0.0/8'),
    ipaddress.ip_network('169.254.0.0/16'),
    ipaddress.ip_network('::1/128'),
    ipaddress.ip_network('fc00::/7'),
    ipaddress.ip_network('fe80::/10'),
]

def is_ip_blocked(ip_str: str) -> bool:
    ip = ipaddress.ip_address(ip_str)
    return any(ip in net for net in BLOCKED_RANGES)

def ssrf_safe_fetch(url: str, timeout: int = 10):
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https'):
        raise ValueError(f"Blocked scheme: {parsed.scheme}")
    for _, _, _, _, sockaddr in socket.getaddrinfo(parsed.hostname, parsed.port):
        if is_ip_blocked(sockaddr[0]):
            raise ValueError(f"Blocked IP: {sockaddr[0]}")
    return requests.get(url, timeout=timeout, allow_redirects=False)

Node.js: SSRF-Safe HTTP Request

// Input:  User-supplied URL string
// Output: HTTP response or throws Error if URL targets blocked IP

const { URL } = require('url');
const dns = require('dns').promises;
const net = require('net');

function isPrivateIP(ip) {
  const p = ip.split('.').map(Number);
  if (p[0] === 10) return true;
  if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
  if (p[0] === 192 && p[1] === 168) return true;
  if (p[0] === 127) return true;
  if (p[0] === 169 && p[1] === 254) return true;
  if (p[0] === 0) return true;
  if (net.isIPv6(ip)) return true;
  return false;
}

async function ssrfSafeFetch(userUrl) {
  const parsed = new URL(userUrl);
  if (!['http:', 'https:'].includes(parsed.protocol))
    throw new Error(`Blocked protocol: ${parsed.protocol}`);
  const { address } = await dns.lookup(parsed.hostname);
  if (isPrivateIP(address))
    throw new Error(`Blocked IP: ${address}`);
  return fetch(userUrl, { redirect: 'manual' });
}

Go: URL Validation with IP Check

// Input:  User-supplied URL string
// Output: *http.Response or error if URL targets blocked IP

package ssrf

import (
    "fmt"; "net"; "net/http"; "net/url"; "time"
)

var privateRanges = []string{
    "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
    "127.0.0.0/8", "169.254.0.0/16", "::1/128",
    "fc00::/7", "fe80::/10",
}

func isBlockedIP(ip net.IP) bool {
    for _, cidr := range privateRanges {
        _, network, _ := net.ParseCIDR(cidr)
        if network.Contains(ip) { return true }
    }
    return false
}

func SafeFetch(rawURL string) (*http.Response, error) {
    parsed, err := url.Parse(rawURL)
    if err != nil { return nil, err }
    if parsed.Scheme != "http" && parsed.Scheme != "https" {
        return nil, fmt.Errorf("blocked scheme: %s", parsed.Scheme)
    }
    ips, _ := net.LookupIP(parsed.Hostname())
    for _, ip := range ips {
        if isBlockedIP(ip) {
            return nil, fmt.Errorf("blocked IP %s", ip)
        }
    }
    client := &http.Client{Timeout: 10 * time.Second,
        CheckRedirect: func(req *http.Request, via []*http.Request) error {
            return http.ErrUseLastResponse
        }}
    return client.Get(rawURL)
}

Java/Spring: SSRF Protection with InetAddress

// Input:  User-supplied URL string
// Output: ResponseEntity or SecurityException if blocked

import java.net.*;

public class SsrfSafeFetcher {
    public static boolean isBlockedIP(InetAddress addr) {
        return addr.isLoopbackAddress()
            || addr.isLinkLocalAddress()
            || addr.isSiteLocalAddress()
            || addr.isAnyLocalAddress()
            || addr.isMulticastAddress();
    }

    public static void validateUrl(String userUrl) throws Exception {
        URL parsed = new URL(userUrl);
        if (!"http".equals(parsed.getProtocol())
            && !"https".equals(parsed.getProtocol()))
            throw new SecurityException("Blocked scheme");
        for (InetAddress addr : InetAddress.getAllByName(parsed.getHost()))
            if (isBlockedIP(addr))
                throw new SecurityException("Blocked IP: " + addr);
    }
}

Anti-Patterns

Wrong: Blocklist-only approach

# BAD -- blocklist filtering is trivially bypassed
BLOCKED = ['127.0.0.1', 'localhost', '169.254.169.254']
def fetch(url):
    host = urlparse(url).hostname
    if host in BLOCKED: raise ValueError("Blocked")
    return requests.get(url)
# Bypass: 0x7f000001, 017700000001, 2130706433, [::1]

Correct: Allowlist with DNS resolution check

# GOOD -- resolve DNS first, then check IP against private ranges
def fetch(url):
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https'): raise ValueError("Blocked scheme")
    for _, _, _, _, sa in socket.getaddrinfo(parsed.hostname, parsed.port):
        if is_ip_blocked(sa[0]): raise ValueError(f"Blocked IP: {sa[0]}")
    return requests.get(url, allow_redirects=False, timeout=10)

Wrong: Trusting redirects after initial validation

# BAD -- allowed.com 302-redirects to http://169.254.169.254/
def fetch(url):
    validate_url(url)  # Passes -- allowed.com is on allowlist
    return requests.get(url, allow_redirects=True)  # Follows redirect to metadata!

Correct: Disable redirects or re-validate each hop

# GOOD -- disable redirects entirely
def fetch(url):
    validate_url(url)
    resp = requests.get(url, allow_redirects=False, timeout=10)
    if 300 <= resp.status_code < 400:
        redirect_url = resp.headers.get('Location')
        validate_url(redirect_url)  # Re-validate redirect target
        return requests.get(redirect_url, allow_redirects=False, timeout=10)
    return resp

Wrong: No DNS resolution check (vulnerable to DNS rebinding)

# BAD -- hostname resolves to public IP during validation,
# then attacker DNS rebinds to 169.254.169.254 on actual request
def fetch(url):
    host = urlparse(url).hostname
    ip = socket.gethostbyname(host)  # 1st resolution: 1.2.3.4 (public)
    if is_private(ip): raise ValueError("Blocked")
    return requests.get(url)  # 2nd resolution: 169.254.169.254 (rebind!)

Correct: Pin DNS resolution and connect by IP

# GOOD -- resolve once, validate, connect directly to resolved IP
def fetch(url):
    parsed = urlparse(url)
    ip = socket.gethostbyname(parsed.hostname)
    if is_ip_blocked(ip): raise ValueError(f"Blocked IP: {ip}")
    safe_url = url.replace(parsed.hostname, ip)
    return requests.get(safe_url, headers={'Host': parsed.hostname},
                        allow_redirects=False, timeout=10)

Common Pitfalls

Diagnostic Commands

# Check if cloud metadata is accessible from instance
curl -s -o /dev/null -w "%{http_code}" http://169.254.169.254/latest/meta-data/

# Verify IMDSv2 enforcement on AWS EC2
aws ec2 describe-instances --instance-ids i-1234567890abcdef0 \
  --query 'Reservations[].Instances[].MetadataOptions'

# Test SSRF protections with IP encoding bypasses
curl -v http://your-app.com/fetch?url=http://2130706433/     # decimal 127.0.0.1
curl -v http://your-app.com/fetch?url=http://0x7f000001/     # hex 127.0.0.1
curl -v http://your-app.com/fetch?url=http://0177.0.0.1/     # octal 127.0.0.1

# Find outbound HTTP requests in Python codebase
grep -rn 'requests\.get\|requests\.post\|urlopen' --include="*.py" .

# Find outbound HTTP requests in Node.js codebase
grep -rn 'http\.get\|https\.get\|fetch(\|axios\.' --include="*.js" --include="*.ts" .

# Check iptables rules blocking metadata IP
iptables -L OUTPUT -n | grep 169.254

Version History & Compatibility

Standard/MilestoneDateKey Change
OWASP Top 10 A10:2021Sep 2021SSRF added as own category
AWS IMDSv2Nov 2019Session-based metadata access (PUT + token)
AWS IMDSv2 default enforcement2024New EC2 instances default to IMDSv2-only
GCP metadata header requirement2020Metadata-Flavor: Google header required
Azure IMDS header requirement2020Metadata: true header required
CWE-918OngoingMITRE weakness entry for SSRF classification
Capital One breachJul 2019SSRF + IMDSv1 exploited for 100M+ customer records

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
App fetches user-supplied URLs (webhooks, image imports, link previews)App only makes requests to hardcoded internal endpointsStatic configuration with no user input in URLs
Accepting webhook callback URLs from third partiesAll outbound URLs are compile-time constantsNo SSRF risk if no user influence on request targets
Running in cloud environments (AWS, GCP, Azure)On-premise with no cloud metadata endpointsStill apply private IP blocking but metadata protection less critical
PDF/image generation from user-supplied content with embedded URLsProcessing only structured data (JSON/XML) with no URL fieldsInput validation for the structured format

Important Caveats

Related Units