SSRF Remediation
Server-Side Request Forgery (SSRF) allows attackers to make requests from your server to internal resources, cloud metadata endpoints, or external systems. This can lead to data exfiltration, internal service access, and cloud credential theft.
Understanding SSRF
High-Risk Targets
- •
169.254.169.254- Cloud metadata (AWS, GCP, Azure) - •
localhost/127.0.0.1- Internal services - •
10.x.x.x, 192.168.x.x- Private networks - •
file://- Local file access - • Internal APIs and admin panels
Common Vulnerable Features
- • URL/image preview generators
- • Webhook integrations
- • File importers (URL-based)
- • PDF generators
- • Proxy/redirect functionality
URL Validation
Allowlist Over Blocklist
Allowlist Approach (Recommended)
import urllib.parse
from ipaddress import ip_address, ip_network
ALLOWED_DOMAINS = {'api.example.com', 'cdn.example.com', 'images.example.com'}
ALLOWED_SCHEMES = {'https'}
def is_url_allowed(url: str) -> bool:
"""Validate URL against allowlist"""
try:
parsed = urllib.parse.urlparse(url)
# Check scheme
if parsed.scheme not in ALLOWED_SCHEMES:
return False
# Check domain against allowlist
if parsed.hostname not in ALLOWED_DOMAINS:
return False
# Prevent port manipulation
if parsed.port and parsed.port not in [443, 80]:
return False
return True
except Exception:
return False
# Usage
url = request.form.get('url')
if not is_url_allowed(url):
abort(400, 'Invalid URL')
response = requests.get(url, timeout=5)Block Private/Reserved IPs
import socket
from ipaddress import ip_address, ip_network
# Define private/reserved ranges
BLOCKED_NETWORKS = [
ip_network('127.0.0.0/8'), # Loopback
ip_network('10.0.0.0/8'), # Private
ip_network('172.16.0.0/12'), # Private
ip_network('192.168.0.0/16'), # Private
ip_network('169.254.0.0/16'), # Link-local (cloud metadata!)
ip_network('0.0.0.0/8'), # Current network
ip_network('100.64.0.0/10'), # Shared address space
ip_network('192.0.0.0/24'), # IETF protocol assignments
ip_network('192.0.2.0/24'), # TEST-NET-1
ip_network('198.51.100.0/24'), # TEST-NET-2
ip_network('203.0.113.0/24'), # TEST-NET-3
ip_network('fc00::/7'), # IPv6 unique local
ip_network('fe80::/10'), # IPv6 link-local
ip_network('::1/128'), # IPv6 loopback
]
def is_ip_allowed(ip_str: str) -> bool:
"""Check if IP is not in blocked ranges"""
try:
ip = ip_address(ip_str)
for network in BLOCKED_NETWORKS:
if ip in network:
return False
return True
except ValueError:
return False
def resolve_and_validate(hostname: str) -> str:
"""Resolve hostname and validate IP"""
try:
ip = socket.gethostbyname(hostname)
if not is_ip_allowed(ip):
raise ValueError(f"Blocked IP: {ip}")
return ip
except socket.gaierror:
raise ValueError("DNS resolution failed")DNS Rebinding Protection
Attackers can bypass IP validation using DNS rebinding - where a hostname resolves to an allowed IP initially, then changes to an internal IP on subsequent requests.
import requests
from requests.adapters import HTTPAdapter
class SSRFSafeAdapter(HTTPAdapter):
"""HTTP adapter that validates IPs before connection"""
def send(self, request, **kwargs):
# Parse hostname from request
from urllib.parse import urlparse
parsed = urlparse(request.url)
hostname = parsed.hostname
# Resolve and validate before each request
ip = socket.gethostbyname(hostname)
if not is_ip_allowed(ip):
raise ValueError(f"Blocked IP: {ip}")
return super().send(request, **kwargs)
# Use custom session
session = requests.Session()
session.mount('http://', SSRFSafeAdapter())
session.mount('https://', SSRFSafeAdapter())
# Make request - IP validated at connection time
response = session.get(url, timeout=5)Cloud Metadata Protection
Critical for Cloud Environments
AWS IMDSv2 (Required Headers)
# AWS - Require IMDSv2 (instance metadata service v2)
# This requires a PUT request with hop limit, blocking SSRF via GET
# Via AWS CLI
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-put-response-hop-limit 1
# Terraform
resource "aws_instance" "example" {
# ... other config
metadata_options {
http_tokens = "required" # Enforce IMDSv2
http_put_response_hop_limit = 1
http_endpoint = "enabled"
}
}Network-Level Blocking
# iptables - Block metadata endpoint from application
iptables -A OUTPUT -d 169.254.169.254 -j DROP
# AWS Security Group - Block outbound to metadata (not always possible)
# Better: Use IMDSv2 and validate at application level
# Docker - Block from containers
docker network create --driver bridge \
--opt com.docker.network.bridge.enable_ip_masquerade=true \
--opt com.docker.network.bridge.host_binding_ipv4=0.0.0.0 \
no-metadata-network
# Kubernetes NetworkPolicy
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/32Secure Architecture
✅ Do
- • Use allowlist for permitted domains/IPs
- • Validate resolved IPs, not just hostnames
- • Implement request timeouts
- • Disable unnecessary URL schemes
- • Use separate service accounts with limited permissions
- • Enable IMDSv2 on all cloud instances
❌ Don't
- • Rely solely on blocklists
- • Trust user-supplied URLs without validation
- • Allow arbitrary URL redirects
- • Expose internal error messages
- • Run with overly permissive IAM roles
- • Allow non-HTTP schemes (file://, gopher://)
Safe Response Handling
def fetch_url_safely(url: str, max_size: int = 1024 * 1024) -> bytes:
"""Fetch URL with safety controls"""
# Validate URL first
if not is_url_allowed(url):
raise ValueError("URL not allowed")
# Resolve and validate IP
from urllib.parse import urlparse
parsed = urlparse(url)
resolve_and_validate(parsed.hostname)
# Make request with safety controls
response = requests.get(
url,
timeout=5, # Connection timeout
allow_redirects=False, # Don't follow redirects (or validate each)
stream=True # Don't load entire response to memory
)
# Check content length
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > max_size:
raise ValueError("Response too large")
# Read with size limit
content = b''
for chunk in response.iter_content(chunk_size=8192):
content += chunk
if len(content) > max_size:
raise ValueError("Response too large")
return content🧪 Testing Verification
SSRF Test Payloads
# Cloud metadata endpoints - should be BLOCKED
http://169.254.169.254/latest/meta-data/
http://metadata.google.internal/computeMetadata/v1/
http://169.254.169.254/metadata/instance
# Internal network - should be BLOCKED
http://127.0.0.1:80
http://localhost:22
http://192.168.1.1
http://10.0.0.1
# Bypass attempts - should also be BLOCKED
http://127.0.0.1.nip.io
http://0x7f000001
http://2130706433
http://[::1]
# DNS rebinding test - use services like rebind.networkExpected Behavior
Allowed URLs
Should succeed - external, allowlisted domains
Blocked URLs
Should return error without making request
⚠️ Common Mistakes
❌ Blocklist-Only Validation
# DON'T: Blocklist is easy to bypass
blocked = ['127.0.0.1', 'localhost']
if host not in blocked:
fetch(url) # Bypassed with 127.0.0.2Hundreds of bypass techniques exist
✅ Correct Approach
# DO: Use strict allowlist
allowed = ['api.trusted.com']
if host in allowed:
fetch(url)Only explicitly allowed hosts can be accessed
❌ URL Validation Before DNS
# DON'T: DNS rebinding possible
if not is_private(parsed.hostname):
fetch(url) # Hostname can resolve
# to 127.0.0.1 after checkDNS can change between check and use
✅ Correct Approach
# DO: Validate resolved IP, pin DNS
ip = socket.gethostbyname(hostname)
if not is_private(ip):
# Use resolved IP directly
fetch_by_ip(ip, hostname)Check actual IP that will be connected to
Verification Checklist
- ☐ URL scheme restricted to https (or http if required)
- ☐ Hostname validated against allowlist
- ☐ Resolved IPs checked against blocked ranges
- ☐ DNS rebinding protection implemented
- ☐ Cloud metadata endpoint (169.254.169.254) blocked
- ☐ IMDSv2 enforced on all cloud instances
- ☐ Redirect following disabled or validated
- ☐ Response size limited
- ☐ Timeouts configured
- ☐ Error messages don't leak internal info