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.
SSRF Attack Surface
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 contentTesting 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
SSRF Security Labs
Practice exploiting and defending against SSRF vulnerabilities.
Evidence to Capture
The request: The full request carrying the URL, with the JSON key or form field identified, and the response. Note whether the fetched body is returned to you or only used server-side.
Internal reach:
The response body from an address only the server can reach — 169.254.169.254/latest/meta-data/, http://127.0.0.1:8500/v1/status, an internal admin page — with the status code and a short excerpt.
Out-of-band interaction: The DNS or HTTP callback on a listener you control, with source address and timestamp. This is the only proof available when the response is not reflected.
Redirect chain: Each hop of the 302 that turned an allowlisted host into an internal target, captured in order, because a single-hop allowlist check often passes the first request and fails the second.
Credential exposure: What the metadata or internal service returned: role name, token prefix, or the unauthenticated internal action performed. Redact the secret but keep enough to size the impact.
Likely False Positives
- Client-side redirect: If the app returns a URL and the browser fetches it, no server-side request occurred. Check the response body, not the rendered page.
- Blocked by egress policy: A timeout after successful DNS resolution means a control is working at the network layer. Report it as residual risk only if some internal path did respond.
- Your own proxy answering: A callback from your test host after a --resolve or proxy misconfiguration is not the target fetching. Compare the source address with the target egress range.
- Allowlisted proxy that rejected the target: A generic error plus an exact-host allowlist is the fix. Confirm by pointing the parameter at a host you control on the allowlist and watching for a hit.
- Rebinding claim without a second resolution: DNS rebinding requires proof that the second resolution was used by the request. One A record change is not enough.
Testing Checklist
Discovery
- ☐ Find every server-side fetch: URL preview, avatar or image proxy, webhook delivery, feed and file importer, PDF or HTML renderer, SSO metadata fetch.
- ☐ Note the scheme and host controls visible in error messages, and whether the URL is validated before or after DNS resolution.
- ☐ Identify the egress context: which network the server fetches from and what it can reach there.
Confirmation
- ☐ Try the internal target by IP, by encoded forms (
2130706433,0177.0.0.1), and by a hostname you control resolving to a private address. - ☐ Route the request through an open redirect on an allowlisted domain and check whether the second hop is revalidated.
- ☐ Use an out-of-band listener whenever the response is not returned, and confirm the source address belongs to the target.
Impact
- ☐ Prove credential access by reading the instance role from the metadata service, or prove an unauthenticated action on an internal service.
- ☐ State whether IMDSv2 is required and what hop limit is set, because
169.254.169.254is only half the story. - ☐ Record the reachable internal surface — other tenants, admin panels, databases — that the fetch context exposes.
Remediation
Structural fix
- • Allowlist exact hosts and schemes, resolve the name yourself, validate the resulting address against private, loopback and link-local ranges, then connect to that resolved address so the check and the connection agree.
- • Disable redirects on the fetch, or follow them manually with each hop revalidated, and refuse non-HTTP schemes outright.
- • Where the destination is a known set, replace the URL parameter with an identifier the server maps to a stored destination, so no user-supplied URL reaches the client library.
Compensating control
- • Force all outbound fetches through an egress proxy that holds the allowlist, and drop direct egress from the application subnet.
- • Require IMDSv2 with a hop limit of one, use a scoped instance role, and block
169.254.0.0/16at the network policy. - • Cap response size and timeout, so a reachable internal endpoint cannot be used to exfiltrate at will.
CWE References: CWE-918