Server-side request forgery lets an attacker make your application’s server issue HTTP requests on their behalf — reaching internal services, cloud metadata APIs, and private networks that were never intended to be accessible from the internet. SSRF attacks surged 452% between 2023 and 2024, driven by AI-powered scanning tools that automate what was previously tedious manual discovery. In cloud environments, SSRF has a particularly high ceiling: a single successful hit on the metadata endpoint can yield IAM credentials with broad permissions.
How SSRF Works
The basic pattern: your application fetches a URL supplied by the user — for an image preview, a webhook, a PDF export service, or a link unfurler. An attacker supplies a URL that resolves to an internal resource.
# VULNERABLE: direct fetch of user-supplied URL
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route("/preview")
def preview():
url = request.args.get("url")
resp = requests.get(url, timeout=5) # attacker can supply http://169.254.169.254/
return resp.content
The attacker calls: /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
On AWS EC2, this returns the instance role credentials — access key, secret key, and session token — with whatever permissions the role holds. From there, the attacker moves laterally through your cloud environment.
Anatomy of a Cloud Metadata SSRF
AWS Instance Metadata Service (IMDSv1) is fully accessible via http://169.254.169.254/ from any EC2 instance. A successful SSRF against this endpoint yields:
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role-name
{
"Code" : "Success",
"Type" : "AWS-HMAC",
"AccessKeyId" : "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey" : "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token" : "AQoDYXdzEJr...",
"Expiration" : "2026-05-24T06:10:09Z"
}
Azure’s equivalent is http://169.254.169.254/metadata/instance?api-version=2021-02-01 (requires Metadata: true header). GCP uses http://metadata.google.internal/computeMetadata/v1/ with a Metadata-Flavor: Google header.
Blind SSRF — where the response isn’t reflected to the attacker — is detected via out-of-band techniques: the attacker supplies a URL pointing to a server they control (e.g., a Burp Collaborator or Interactsh endpoint) and monitors for DNS lookups or HTTP callbacks, confirming the server made the request even without seeing the response.
Vulnerable Code Patterns to Avoid
Pattern 1: Redirect-following without validation
# VULNERABLE: follows redirects, which can bypass IP-based blocklists
# An attacker uses: http://attacker.com/redirect?to=http://169.254.169.254/
response = requests.get(user_url, allow_redirects=True, timeout=5)
Pattern 2: DNS rebinding bypass
Validating the hostname at request-time is insufficient. An attacker can register a domain that resolves to a public IP on first lookup (passing your validation) and then rebinds to 169.254.169.254 by the time your code makes the actual HTTP request.
Pattern 3: Scheme bypass
If you only block http://169.254.169.254, attackers try alternative schemes:
http://[::ffff:169.254.169.254]/— IPv6 representationhttp://0251.0376.0251.0376/— octal encodinghttp://2852039166/— decimal representation of169.254.169.254http://169.254.169.254.nip.io/— wildcard DNS service
Secure Code Patterns
Python: Allowlist with resolved IP validation
import ipaddress
import socket
import urllib.parse
import requests
from requests.exceptions import SSLError, ConnectionError
# Allowlist of permitted domains/hosts
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
# Private and link-local IP ranges to block
BLOCKED_NETWORKS = [
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("169.254.0.0/16"), # link-local / AWS metadata
ipaddress.ip_network("127.0.0.0/8"), # loopback
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("fc00::/7"), # IPv6 unique local
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
]
def is_safe_ip(ip_str: str) -> bool:
try:
addr = ipaddress.ip_address(ip_str)
return not any(addr in net for net in BLOCKED_NETWORKS)
except ValueError:
return False
def safe_fetch(user_url: str, timeout: int = 5) -> bytes:
parsed = urllib.parse.urlparse(user_url)
# Only allow http and https — block file://, ftp://, gopher://, etc.
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Disallowed scheme: {parsed.scheme}")
# Allowlist check — only proceed if host is in the permitted list
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError(f"Host not in allowlist: {parsed.hostname}")
# Resolve DNS now and validate the resulting IP before making the request
# This mitigates (but does not fully eliminate) DNS rebinding
try:
resolved_ip = socket.gethostbyname(parsed.hostname)
except socket.gaierror:
raise ValueError(f"DNS resolution failed for {parsed.hostname}")
if not is_safe_ip(resolved_ip):
raise ValueError(f"Resolved IP {resolved_ip} is in a blocked range")
# Disable redirects — validate redirect targets separately if required
response = requests.get(
user_url,
timeout=timeout,
allow_redirects=False, # do not follow redirects automatically
verify=True, # enforce TLS cert validation
)
# Validate content type matches expectation before returning
content_type = response.headers.get("Content-Type", "")
if not content_type.startswith("image/"):
raise ValueError(f"Unexpected content type: {content_type}")
return response.content
Node.js: URL validation with allowlist
const https = require('https');
const dns = require('dns').promises;
const { URL } = require('url');
const ipaddr = require('ipaddr.js'); // npm install ipaddr.js
const ALLOWED_HOSTS = new Set(['api.example.com', 'cdn.example.com']);
const BLOCKED_CIDRS = [
'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'
].map(cidr => ipaddr.parseCIDR(cidr));
function isBlockedIP(ip) {
try {
const addr = ipaddr.process(ip);
return BLOCKED_CIDRS.some(([network, prefix]) => addr.match([network, prefix]));
} catch {
return true; // block if unparseable
}
}
async function safeFetch(userUrl) {
let parsed;
try {
parsed = new URL(userUrl);
} catch {
throw new Error('Invalid URL');
}
// Scheme allowlist
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Disallowed protocol: ${parsed.protocol}`);
}
// Host allowlist
if (!ALLOWED_HOSTS.has(parsed.hostname)) {
throw new Error(`Host not permitted: ${parsed.hostname}`);
}
// Resolve and validate IP before connecting
const addresses = await dns.lookup(parsed.hostname, { all: true });
for (const { address } of addresses) {
if (isBlockedIP(address)) {
throw new Error(`Resolved IP ${address} is in a blocked range`);
}
}
return new Promise((resolve, reject) => {
const req = https.get(parsed.toString(), {
timeout: 5000,
headers: { 'User-Agent': 'MyApp/1.0' }
}, (res) => {
// Never follow redirects without re-validating the target
if (res.statusCode >= 300 && res.statusCode < 400) {
return reject(new Error('Redirects not followed without re-validation'));
}
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
});
}
Infrastructure-Level Controls
Application-layer defences are necessary but insufficient. The infrastructure must reinforce them:
Enforce IMDSv2 on all EC2 instances. IMDSv2 requires a PUT-based session token before any GET request to the metadata service, and that PUT request requires a specific header that typical SSRF payloads don’t include:
# Enforce IMDSv2 on a running instance
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled
# Or in Terraform:
resource "aws_instance" "app" {
metadata_options {
http_tokens = "required" # IMDSv2 only
http_endpoint = "enabled"
http_put_response_hop_limit = 1 # blocks container SSRF to host metadata
}
}
Network egress controls: use security groups or NACLs to restrict which internal subnets application servers can reach. Application instances should not have direct network access to database subnets, management networks, or other application tiers unless explicitly required.
Log and alert on metadata service access: monitor for unexpected calls to 169.254.169.254 in your VPC flow logs or WAF logs — these should be rare and easily attributable to legitimate instance configuration tooling.
Testing Your SSRF Defences
Use a DNS-based out-of-band callback server (Burp Collaborator, interactsh.com) to verify that your application does not make outbound requests to attacker-controlled infrastructure when a URL is supplied in relevant parameters. Test all URL-consuming features: webhooks, PDF exports, image imports, link previews, and any integration that fetches remote resources.