Server-Side Request Forgery (SSRF): Prevention
Server-Side Request Forgery occurs when an attacker manipulates your server into making HTTP requests to unintended destinations. Unlike client-side attacks, SSRF exploits the trust your server has...
Key Insights
- SSRF vulnerabilities let attackers weaponize your server to access internal resources, cloud metadata services, and private networks—making your trusted infrastructure the attack vector.
- Defense requires layered controls: strict URL allowlisting, IP range blocking, DNS rebinding protection, and network segmentation working together.
- Cloud environments are especially vulnerable; AWS metadata endpoints have been exploited in major breaches, making IMDSv2 and proper network controls essential.
Introduction to SSRF Attacks
Server-Side Request Forgery occurs when an attacker manipulates your server into making HTTP requests to unintended destinations. Unlike client-side attacks, SSRF exploits the trust your server has within your network. Your application server can reach internal services, cloud metadata endpoints, and private APIs that external attackers cannot directly access.
The danger is straightforward: your server becomes a proxy for attackers. They submit a malicious URL, your server fetches it, and suddenly they’re reading your AWS credentials from the metadata service or scanning your internal network.
Here’s a vulnerable endpoint that many applications inadvertently create:
from flask import Flask, request
import requests
app = Flask(__name__)
@app.route('/fetch-preview')
def fetch_preview():
# VULNERABLE: No validation on user-provided URL
url = request.args.get('url')
response = requests.get(url, timeout=10)
return {
'content': response.text[:500],
'status': response.status_code
}
This looks innocent—maybe it’s generating link previews or fetching RSS feeds. But an attacker can submit http://169.254.169.254/latest/meta-data/iam/security-credentials/ and your server will happily return AWS credentials.
Common SSRF Attack Vectors
SSRF vulnerabilities hide in features you wouldn’t expect. Any functionality that fetches external resources is a potential target.
URL parameters are the obvious case—image fetchers, link previewers, and URL shorteners. But attackers also target webhook endpoints where users configure callback URLs, file import features that accept URLs (CSV imports, image uploads by URL), and PDF generators that render HTML from external sources.
The payloads vary by target. Cloud metadata services are the crown jewels:
# AWS metadata (IMDSv1)
http://169.254.169.254/latest/meta-data/
http://169.254.169.254/latest/meta-data/iam/security-credentials/[role-name]
# GCP metadata
http://metadata.google.internal/computeMetadata/v1/
# Azure metadata
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# Common internal targets
http://localhost:8080/admin
http://127.0.0.1:6379/ # Redis
http://192.168.1.1/ # Internal network scanning
Attackers also use protocol handlers to bypass naive URL validation:
file:///etc/passwd
gopher://internal-service:6379/_*1%0d%0a$4%0d%0aINFO%0d%0a
dict://internal-host:11211/stat
The gopher protocol is particularly dangerous—it allows crafting arbitrary TCP packets, enabling attacks against Redis, Memcached, and other internal services.
Input Validation and URL Allowlisting
Your first defense is strict input validation. Don’t just block known-bad patterns—explicitly allow only what you need.
from urllib.parse import urlparse
import ipaddress
import socket
class URLValidator:
ALLOWED_SCHEMES = {'http', 'https'}
ALLOWED_DOMAINS = {
'api.github.com',
'api.twitter.com',
'cdn.example.com',
}
def validate(self, url: str) -> bool:
try:
parsed = urlparse(url)
# Check scheme
if parsed.scheme not in self.ALLOWED_SCHEMES:
return False
# Check for empty or suspicious hostnames
if not parsed.hostname:
return False
# Allowlist check
if parsed.hostname not in self.ALLOWED_DOMAINS:
return False
# Block suspicious ports (only allow 80, 443)
if parsed.port and parsed.port not in (80, 443):
return False
return True
except Exception:
return False
def get_safe_url(self, url: str) -> str | None:
if not self.validate(url):
return None
return url
For Node.js applications:
const { URL } = require('url');
const ALLOWED_DOMAINS = new Set([
'api.github.com',
'api.twitter.com',
'cdn.example.com'
]);
function validateURL(urlString) {
try {
const parsed = new URL(urlString);
// Scheme check
if (!['http:', 'https:'].includes(parsed.protocol)) {
return { valid: false, reason: 'Invalid protocol' };
}
// Domain allowlist
if (!ALLOWED_DOMAINS.has(parsed.hostname)) {
return { valid: false, reason: 'Domain not allowed' };
}
// Block non-standard ports
if (parsed.port && !['80', '443', ''].includes(parsed.port)) {
return { valid: false, reason: 'Invalid port' };
}
// Block credentials in URL
if (parsed.username || parsed.password) {
return { valid: false, reason: 'Credentials not allowed' };
}
return { valid: true, url: parsed.href };
} catch (e) {
return { valid: false, reason: 'Invalid URL format' };
}
}
Be careful with URL parsing. Attackers use encoding tricks, Unicode characters, and parser inconsistencies to bypass validation. Always parse before validating, and re-serialize the parsed URL rather than using the original string.
Network-Level Protections
URL validation alone isn’t enough. Attackers bypass domain allowlists through DNS rebinding—a domain resolves to an allowed IP initially, then switches to an internal IP when your server makes the request.
Block private IP ranges at the network level:
import ipaddress
import socket
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('127.0.0.0/8'),
ipaddress.ip_network('169.254.0.0/16'), # Link-local (includes AWS metadata)
ipaddress.ip_network('0.0.0.0/8'),
ipaddress.ip_network('::1/128'), # IPv6 loopback
ipaddress.ip_network('fc00::/7'), # IPv6 private
ipaddress.ip_network('fe80::/10'), # IPv6 link-local
]
def is_ip_blocked(ip_string: str) -> bool:
try:
ip = ipaddress.ip_address(ip_string)
return any(ip in network for network in BLOCKED_NETWORKS)
except ValueError:
return True # Invalid IP = blocked
def resolve_and_validate(hostname: str) -> str | None:
"""Resolve hostname and validate the resulting IP."""
try:
# Get all IPs for hostname
results = socket.getaddrinfo(hostname, None)
for result in results:
ip = result[4][0]
if is_ip_blocked(ip):
return None
# Return first valid IP
return results[0][4][0]
except socket.gaierror:
return None
def safe_fetch(url: str) -> dict:
"""Fetch URL with DNS rebinding protection."""
parsed = urlparse(url)
# Resolve and validate before request
resolved_ip = resolve_and_validate(parsed.hostname)
if not resolved_ip:
raise ValueError("Blocked destination")
# Make request to IP directly, with Host header
# This prevents DNS rebinding during the actual request
ip_url = url.replace(parsed.hostname, resolved_ip)
response = requests.get(
ip_url,
headers={'Host': parsed.hostname},
timeout=10,
allow_redirects=False # Handle redirects manually
)
return {'status': response.status_code, 'content': response.text}
Disabling automatic redirects is critical. Attackers use open redirects on allowed domains to bounce requests to internal targets.
Secure Architecture Patterns
The safest approach isolates URL fetching into a dedicated service with restricted network access:
# proxy_service.py - Runs in isolated network segment
from flask import Flask, request, jsonify
import requests
from functools import wraps
import hmac
import hashlib
app = Flask(__name__)
SHARED_SECRET = os.environ['PROXY_SECRET']
def verify_request_signature(f):
@wraps(f)
def decorated(*args, **kwargs):
signature = request.headers.get('X-Request-Signature')
timestamp = request.headers.get('X-Request-Timestamp')
if not signature or not timestamp:
return jsonify({'error': 'Missing signature'}), 401
# Verify timestamp is recent (prevent replay)
if abs(time.time() - float(timestamp)) > 30:
return jsonify({'error': 'Request expired'}), 401
# Verify signature
payload = f"{timestamp}:{request.get_data(as_text=True)}"
expected = hmac.new(
SHARED_SECRET.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
return jsonify({'error': 'Invalid signature'}), 401
return f(*args, **kwargs)
return decorated
@app.route('/fetch', methods=['POST'])
@verify_request_signature
def proxy_fetch():
data = request.json
url = data.get('url')
# All validation logic here
validator = URLValidator()
if not validator.validate(url):
return jsonify({'error': 'URL not allowed'}), 400
# IP validation
parsed = urlparse(url)
resolved_ip = resolve_and_validate(parsed.hostname)
if not resolved_ip:
return jsonify({'error': 'Blocked destination'}), 400
# Fetch with restrictions
response = requests.get(
url,
timeout=10,
allow_redirects=False,
headers={'User-Agent': 'SafeProxy/1.0'}
)
return jsonify({
'status': response.status_code,
'headers': dict(response.headers),
'content': response.text[:10000] # Limit response size
})
This proxy service runs in a network segment that can only reach the public internet—no access to internal services, metadata endpoints, or private networks. Your main application calls the proxy; it never makes outbound requests directly.
Cloud-Specific Mitigations
AWS’s Instance Metadata Service v1 is notoriously vulnerable to SSRF. A single request to 169.254.169.254 returns credentials. IMDSv2 requires a session token obtained via a PUT request, which most SSRF vulnerabilities can’t perform:
# Enforce IMDSv2 on EC2 instances
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled
# Or in Terraform
resource "aws_instance" "web" {
# ... other config
metadata_options {
http_tokens = "required" # Enforces IMDSv2
http_endpoint = "enabled"
}
}
For containers, disable metadata access entirely when possible:
# ECS Task Definition
{
"containerDefinitions": [{
"name": "app",
"image": "myapp:latest",
"linuxParameters": {
"initProcessEnabled": true
}
}],
"networkMode": "awsvpc",
"taskRoleArn": "arn:aws:iam::123456789:role/minimal-role"
}
In Kubernetes, use Network Policies to block metadata access:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: block-metadata
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 169.254.169.254/32
Testing and Monitoring
Log all outbound requests with enough detail to detect SSRF attempts:
import logging
from datetime import datetime
class SSRFMonitor:
SUSPICIOUS_PATTERNS = [
'169.254.',
'127.0.0.1',
'localhost',
'10.',
'192.168.',
'172.16.', '172.17.', '172.18.', # ... through 172.31.
'metadata',
'0.0.0.0',
]
def __init__(self):
self.logger = logging.getLogger('ssrf_monitor')
def log_request(self, url: str, user_id: str, source_ip: str):
is_suspicious = any(
pattern in url.lower()
for pattern in self.SUSPICIOUS_PATTERNS
)
log_entry = {
'timestamp': datetime.utcnow().isoformat(),
'url': url,
'user_id': user_id,
'source_ip': source_ip,
'suspicious': is_suspicious,
}
if is_suspicious:
self.logger.warning(f"SSRF_ATTEMPT: {log_entry}")
# Alert security team
self.send_alert(log_entry)
else:
self.logger.info(f"OUTBOUND_REQUEST: {log_entry}")
def send_alert(self, log_entry: dict):
# Integration with your alerting system
pass
Test your defenses with tools like Burp Suite’s Collaborator or build custom test scripts that attempt common bypass techniques: URL encoding, IPv6 representations, decimal IP notation, and DNS rebinding.
SSRF prevention isn’t a single control—it’s defense in depth. Validate inputs, block dangerous destinations, isolate services, and monitor everything. When one layer fails, others catch the attack.