Server-Side Request Forgery (SSRF) is the vulnerability that makes your application’s HTTP client work for the attacker. When an application accepts a URL as user input and makes an HTTP request to that URL without adequate validation, an attacker can point that request anywhere the server can reach — the internal network, cloud metadata services, localhost services, and any other system that trusts requests from the server’s IP address.
SSRF reached OWASP A10 in the 2021 list and holds that position in 2025 and 2026, largely because cloud environments dramatically expanded its impact. On an EC2 instance, a GCE VM, or an Azure Virtual Machine, the metadata endpoint at 169.254.169.254 or fd00:ec2::254 returns IAM credentials for the instance’s service account — available to any process that can reach it, including an SSRF-exploiting application.
Where SSRF Hides in Applications
The vulnerability appears anywhere user input influences an outbound HTTP request:
- URL fetch / webhook functionality — “Enter a URL to import data from”; webhook destinations that the application calls
- PDF/image generation — libraries that render HTML to PDF resolve
<img>and CSS URLs from the page - File import by URL — import spreadsheet from URL, import image from link
- XML parsers — XXE’s HTTP entity resolution (a related vulnerability class)
- DNS rebinding-adjacent patterns — any redirect-following HTTP client that re-resolves hostnames
Classic Attack: SSRF to Cloud Metadata
On AWS EC2, an attacker supplies a URL pointing to the instance metadata endpoint:
# Attacker submits this as the "import URL":
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Application makes the request server-side
# Response contains the IAM role name, e.g.: my-app-role
# Second request retrieves credentials:
http://169.254.169.254/latest/meta-data/iam/security-credentials/my-app-role
# Response:
{
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"Token": "IQoJb3Jp...",
"Expiration": "2026-06-05T14:00:00Z"
}
The attacker now has temporary AWS credentials usable from any internet-connected machine.
Why Blocklists Fail
The instinct is to block known-dangerous IPs: 127.0.0.1, 169.254.169.254, ::1, 10.0.0.0/8. This approach fails because:
DNS rebinding: A hostname resolves to a public IP at validation time, but the DNS record TTL is set to near-zero. By the time the HTTP client makes the connection, the DNS resolves to 169.254.169.254 or an internal IP.
1. Validator checks: resolve("attacker.com") → 1.2.3.4 ✓ not blocked
2. HTTP client calls: connect("attacker.com") → DNS re-resolves → 169.254.169.254 ✗
IP format bypasses: Many HTTP clients accept non-standard IP representations that bypass naive string checks:
http://0x7f000001/→ 127.0.0.1 in hexhttp://2130706433/→ 127.0.0.1 in decimalhttp://0177.0.0.1/→ 127.0.0.1 in octalhttp://[::ffff:127.0.0.1]/→ IPv4-mapped IPv6
Redirect chains: The initial request goes to a permitted host that returns a 301/302 redirect to an internal address.
The Right Approach: Allowlist + Dedicated Fetch Service
Option 1: Domain Allowlist
If your application only legitimately needs to fetch from a known set of domains, enforce an allowlist at the domain level and make the HTTP request only if the resolved URL is within those domains:
from urllib.parse import urlparse
import ipaddress
import socket
import requests
ALLOWED_DOMAINS = {
"api.example.com",
"cdn.example.com",
"webhooks.trusted-partner.com",
}
def safe_fetch(url: str) -> requests.Response:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Scheme not allowed: {parsed.scheme}")
hostname = parsed.hostname
if not hostname or hostname.lower() not in ALLOWED_DOMAINS:
raise ValueError(f"Domain not in allowlist: {hostname}")
# Resolve and validate the IP — guard against DNS rebinding at connection
# by using a custom socket that validates the resolved IP
return requests.get(url, timeout=10, allow_redirects=False)
Setting allow_redirects=False prevents redirect-based bypasses. If redirects are needed, re-validate the destination URL.
Option 2: Validate the Resolved IP
When the domain set is open-ended (user-supplied webhooks, general URL fetchers), validate the IP address the hostname resolves to before making the request:
import ipaddress
import socket
from urllib.parse import urlparse
import requests
def is_safe_ip(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return not any([
ip.is_private,
ip.is_loopback,
ip.is_link_local, # 169.254.0.0/16 — metadata services
ip.is_multicast,
ip.is_reserved,
ip.is_unspecified,
])
def safe_fetch_with_ip_validation(url: str, timeout: int = 10) -> requests.Response:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Disallowed scheme: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("No hostname in URL")
# Resolve the hostname and validate all returned IPs
try:
resolved = socket.getaddrinfo(hostname, parsed.port or 443)
except socket.gaierror as e:
raise ValueError(f"DNS resolution failed: {e}")
for _, _, _, _, sockaddr in resolved:
ip = sockaddr[0]
if not is_safe_ip(ip):
raise ValueError(f"Resolved to disallowed IP: {ip}")
# For production use, consider a library that enforces the same IP
# at connection time to prevent DNS rebinding (see ssrf-safe, pycurl with curlopt)
return requests.get(url, timeout=timeout, allow_redirects=False)
DNS rebinding mitigation: The check above is still vulnerable to DNS rebinding between resolution and connection. The robust solution is to resolve once, validate, and then make the HTTP request directly to the validated IP address while passing the original hostname in the Host header:
import requests
def safe_fetch_pinned(url: str) -> requests.Response:
parsed = urlparse(url)
hostname = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
# Resolve once
resolved_ip = socket.gethostbyname(hostname)
if not is_safe_ip(resolved_ip):
raise ValueError(f"IP {resolved_ip} is not publicly routable")
# Reconstruct URL using resolved IP to prevent re-resolution
safe_url = url.replace(hostname, resolved_ip, 1)
return requests.get(
safe_url,
headers={"Host": hostname},
timeout=10,
allow_redirects=False,
verify=True,
)
Go — Custom Dialer with IP Validation
Go’s net/http package makes it straightforward to intercept the connection at the dialer level, where you can validate the resolved IP before the connection is established — this prevents DNS rebinding:
package main
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"time"
)
func isPrivateIP(ip net.IP) bool {
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", // Link-local / metadata services
"::1/128",
"fc00::/7",
"fe80::/10",
}
for _, cidr := range privateRanges {
_, network, _ := net.ParseCIDR(cidr)
if network.Contains(ip) {
return true
}
}
return false
}
func newSafeHTTPClient() *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second}
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// Resolve and validate IPs before connecting
ips, err := net.LookupHost(host)
if err != nil {
return nil, fmt.Errorf("DNS lookup failed: %w", err)
}
for _, ipStr := range ips {
ip := net.ParseIP(ipStr)
if ip == nil || isPrivateIP(ip) {
return nil, fmt.Errorf("disallowed IP address: %s", ipStr)
}
}
// Connect to the first valid resolved IP directly
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port))
},
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse // Disable redirects
},
}
}
func safeFetch(rawURL string) (*http.Response, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("disallowed scheme: %s", u.Scheme)
}
client := newSafeHTTPClient()
return client.Get(rawURL)
}
The custom DialContext function resolves the hostname, validates every returned IP against the private range list, and only then establishes the connection. Any redirect that changes the resolved IP will be caught on the next connection attempt.
Node.js — ssrf-req-filter
The ssrf-req-filter library provides SSRF protection for Node.js HTTP requests:
import { Agent } from 'ssrf-req-filter';
import https from 'https';
// Create a safe agent that blocks private IPs
const safeAgent = new Agent();
// Use with node's built-in fetch or http
const response = await fetch(userSuppliedUrl, {
agent: safeAgent,
redirect: 'manual', // Don't auto-follow redirects
signal: AbortSignal.timeout(10000),
});
Scheme Restrictions
Beyond IP validation, restrict URL schemes. Applications almost never need file://, gopher://, dict://, or ftp:// — these schemes can bypass IP-based controls and access local resources:
ALLOWED_SCHEMES = {"http", "https"}
def validate_scheme(url: str) -> None:
parsed = urlparse(url)
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise ValueError(f"URL scheme '{parsed.scheme}' is not permitted")
Testing for SSRF
Basic test payloads:
# Cloud metadata
http://169.254.169.254/latest/meta-data/
http://metadata.google.internal/computeMetadata/v1/
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# Localhost services
http://127.0.0.1:8080/
http://localhost:6379/ # Redis
http://0.0.0.0/
# Encoding bypasses
http://0x7f000001/
http://[::1]/
http://[0:0:0:0:0:ffff:127.0.0.1]/
Use Burp Collaborator or interactsh for blind SSRF detection — a DNS callback confirms the server is resolving attacker-controlled URLs even when the response is not reflected.