Server-Side Request Forgery (SSRF) Prevention
Server-Side Request Forgery occurs when an application fetches a remote resource without adequately validating the user-supplied URL, turning the server into an unwitting proxy for attacker-controlled requests. In cloud-native architectures, SSRF transforms from a theoretical vulnerability into a critical infrastructure compromise vector: attackers leverage it to access internal microservices, bypass network segmentation, and exfiltrate cloud metadata endpoints carrying IAM credentials. This guide is part of the Vulnerability Patterns & Web Mitigation Strategies reference, and sits alongside related controls for Cross-Site Request Forgery (CSRF) defense and injection attack prevention. Effective mitigation requires defense-in-depth spanning application-layer validation, network egress controls, and runtime enforcement — each layer addressed in depth below.
Threat Anatomy
SSRF manifests in two primary forms. Full SSRF returns the fetched resource directly to the attacker, exposing internal service responses verbatim. Blind SSRF reveals success through out-of-band channels — DNS lookups, HTTP callbacks to attacker-controlled servers, or response timing differences — making it harder to detect but equally dangerous.
Modern exploitation frequently abuses alternative protocol handlers. Where HTTP/HTTPS are blocked, attackers pivot to gopher://, dict://, file://, and ftp:// to interact with internal services or trigger memory-corruption in legacy protocol parsers. Redirect chains compound the risk: an initial https:// request that the application permits may follow a 30x redirect to an internal http:// address, bypassing scheme validation entirely.
MITRE ATT&CK mapping: SSRF maps to technique T1190 (Exploit Public-Facing Application) for initial access and T1552.005 (Cloud Instance Metadata API) for credential access. Cloud SSRF also enables T1078.004 (Valid Accounts: Cloud Accounts) when metadata tokens are harvested and replayed.
Cloud Metadata Exploitation
Cloud environments amplify SSRF impact through Instance Metadata Services (IMDS). The link-local endpoint 169.254.169.254 exposes temporary IAM tokens, user data, SSH keys, and internal network topology. A single successful SSRF request to /latest/meta-data/iam/security-credentials/<role> returns a set of temporary AWS credentials valid for hours. The attacker gains lateral movement and privilege escalation without ever touching the credential store.
GCP and Azure have equivalent metadata paths (metadata.google.internal, 169.254.169.254/metadata/instance). The SSRF allowlists and metadata service protection deep-dive covers per-provider hardening including IMDSv2 enforcement.
Unlike client-side request manipulation (covered in CSRF defense), SSRF exploits server-side trust boundaries. The server acts as a privileged proxy, inheriting its network position inside the perimeter. Applying the STRIDE framework to outbound request handlers surfaces the following threats:
| STRIDE Category | Threat | Example |
|---|---|---|
| Information Disclosure | Metadata token exfiltration | GET http://169.254.169.254/latest/meta-data/iam/security-credentials/ |
| Elevation of Privilege | Internal admin panel access | GET http://10.0.0.1:8080/admin/ via SSRF |
| Spoofing | Impersonating internal services | Forging source IP via SSRF chaining |
| Denial of Service | Slow-loris against internal endpoints | Targeting slow internal HTTP services to exhaust thread pool |
Prerequisites & Scope
Before applying these controls, confirm the following are in place:
- The application accepts user-supplied or attacker-influenced URLs, file paths, or hostnames (webhook endpoints, image import URLs, PDF generation services, URL preview fetchers, webhook delivery systems).
- Backend services make outbound HTTP/HTTPS calls on behalf of users, even indirectly (e.g., fetching Open Graph metadata from a user-supplied link).
- Cloud-hosted workloads that run on AWS, GCP, or Azure where the IMDS is reachable from compute instances.
- Microservice environments where inter-service calls traverse the same network segment as external-facing request handlers.
- Infrastructure controlled by the team (or clearly documented if using a managed egress proxy), so network-layer controls can be enforced.
The controls here apply to any language or framework that makes outbound HTTP calls. Code examples use Python, TypeScript, and Go — the primary stacks for backend web services. Kubernetes-specific controls (Istio EgressGateway, NetworkPolicy) are highlighted where they apply.
Mitigation Architecture
The diagram below shows an SSRF-hardened request flow. Every outbound call passes through three validation checkpoints: scheme/URL parsing, pre-flight DNS resolution with IP validation, and network-layer egress filtering. A request is only forwarded if it clears all three.
The three-checkpoint model is deliberately redundant. An attacker who defeats one layer (for example, by crafting a URL that slips through a naive regex parser) still faces DNS pre-resolution at the second layer, then network egress filtering at the third. No single bypass defeats all three simultaneously.
Step-by-Step Implementation
Step 1 — Strict URL Parsing and Scheme Allowlisting (OWASP ASVS 5.2.6 / NIST SP 800-53 SI-10)
Use the standard library URL parser for the runtime — never write a custom regex to parse URLs. Reject any request whose scheme is not explicitly http or https. Reject embedded credentials (user:pass@host), fragments, and non-standard ports unless explicitly required.
import { URL } from 'url';
const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
export function parseAndValidateURL(raw: string): URL {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error('Invalid URL: cannot be parsed');
}
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
throw new Error(`Unsupported scheme: ${parsed.protocol}`);
}
// Reject embedded credentials — these are unused by modern servers
// but can confuse parsers and enable bypass via user@host tricks
if (parsed.username || parsed.password) {
throw new Error('URLs with embedded credentials are not permitted');
}
return parsed;
}
Step 2 — Pre-flight DNS Resolution and IP Blocklisting (OWASP ASVS 5.2.7 / NIST SP 800-53 SC-7)
After parsing, resolve the hostname to its IP address before opening any connection. Validate the resolved IP against a comprehensive blocklist covering all private, loopback, link-local, and multicast ranges in both IPv4 and IPv6.
import ipaddress
import socket
import urllib.parse
from typing import Optional
BLOCKED_NETWORKS = [
# RFC 1918 private ranges
ipaddress.ip_network('10.0.0.0/8'),
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
# Loopback
ipaddress.ip_network('127.0.0.0/8'),
ipaddress.ip_network('::1/128'),
# Link-local / cloud metadata
ipaddress.ip_network('169.254.0.0/16'),
ipaddress.ip_network('fe80::/10'),
# "This" network
ipaddress.ip_network('0.0.0.0/8'),
# Unique local (ULA)
ipaddress.ip_network('fc00::/7'),
# Multicast
ipaddress.ip_network('224.0.0.0/4'),
ipaddress.ip_network('ff00::/8'),
]
def resolve_and_validate(hostname: str) -> str:
"""Resolve hostname and return the validated IP string, or raise."""
try:
# getaddrinfo returns all address families; take the first result
addrinfo = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError(f'DNS resolution failed for {hostname!r}: {exc}') from exc
for family, _, _, _, sockaddr in addrinfo:
ip_str = sockaddr[0]
try:
ip_obj = ipaddress.ip_address(ip_str)
except ValueError:
raise ValueError(f'Could not parse resolved address: {ip_str!r}')
# Normalise IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1)
if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped:
ip_obj = ip_obj.ipv4_mapped
for network in BLOCKED_NETWORKS:
if ip_obj in network:
raise ValueError(
f'Resolved IP {ip_str!r} for {hostname!r} is in blocked range {network}'
)
# Return the first successfully validated IP for connection pinning
return addrinfo[0][4][0]
def secure_fetch(url: str, timeout: tuple[float, float] = (3.0, 5.0)):
import requests
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError('Only http/https schemes are allowed')
resolve_and_validate(parsed.hostname)
# disable_redirects: a 301 to an internal host is a classic SSRF bypass
return requests.get(url, timeout=timeout, allow_redirects=False)
Step 3 — DNS Rebinding Mitigation via Connection Pinning (OWASP ASVS 5.2.8)
Standard pre-flight resolution has a Time-of-Check to Time-of-Use (TOCTOU) gap: an attacker-controlled DNS record can return a public IP during validation, then switch to an internal IP before the TCP connection opens. Pin the resolved IP by connecting directly to the IP address while preserving the Host header for TLS SNI and virtual hosting.
In Go, override DialContext to intercept the actual connection and verify the IP a second time at connect time:
package main
import (
"context"
"fmt"
"net"
"net/http"
"time"
)
var blockedCIDRs = []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", "0.0.0.0/8",
"::1/128", "fc00::/7", "fe80::/10",
}
var parsedCIDRs []*net.IPNet
func init() {
for _, cidr := range blockedCIDRs {
_, ipNet, _ := net.ParseCIDR(cidr)
parsedCIDRs = append(parsedCIDRs, ipNet)
}
}
func isBlocked(ip net.IP) bool {
// Normalise IPv4-in-IPv6 representation
if v4 := ip.To4(); v4 != nil {
ip = v4
}
for _, cidr := range parsedCIDRs {
if cidr.Contains(ip) {
return true
}
}
return false
}
// secureDialContext resolves the hostname itself and validates the IP
// before handing the pinned address to the dialer, eliminating the
// TOCTOU window between pre-flight validation and actual TCP connect.
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("invalid address %q: %w", addr, err)
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("dns lookup failed for %q: %w", host, err)
}
for _, ipAddr := range ips {
if isBlocked(ipAddr.IP) {
return nil, fmt.Errorf("resolved IP %s for %q is in a blocked range", ipAddr.IP, host)
}
}
// Connect directly to the resolved IP, pinning the address
pinned := net.JoinHostPort(ips[0].IP.String(), port)
d := &net.Dialer{Timeout: 3 * time.Second}
return d.DialContext(ctx, network, pinned)
}
func NewSecureHTTPClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: secureDialContext,
},
// Prevent redirect-based SSRF: a 301 to an internal address bypasses
// the pre-flight check on the original URL
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
Step 4 — Network-Layer Egress Filtering (NIST SP 800-53 SC-7 / SOC 2 CC6.6)
Application-layer validation is necessary but not sufficient. Add a second enforcement point at the network layer so that a validation bypass in application code still cannot reach internal addresses.
Kubernetes NetworkPolicy — default-deny egress with explicit external-only permit:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-internal-egress
namespace: production
spec:
podSelector:
matchLabels:
app: webhook-fetcher
policyTypes:
- Egress
egress:
# Allow DNS
- ports:
- protocol: UDP
port: 53
# Allow outbound HTTPS to public internet only
# (non-RFC-1918, non-link-local)
- ports:
- protocol: TCP
port: 443
- protocol: TCP
port: 80
# No ipBlock.cidr here means all destinations are permitted
# by this rule; the absence of RFC 1918 blocks is enforced
# by the explicit deny rules below via a dedicated egress gateway.
Istio ServiceEntry + Sidecar — explicit external domain allowlist:
apiVersion: networking.istio.io/v1beta1
kind: ServiceEntry
metadata:
name: allowed-external-apis
spec:
hosts:
- "api.stripe.com"
- "hooks.slack.com"
ports:
- number: 443
name: https
protocol: HTTPS
location: MESH_EXTERNAL
resolution: DNS
---
apiVersion: networking.istio.io/v1beta1
kind: Sidecar
metadata:
name: webhook-fetcher-sidecar
spec:
workloadSelector:
labels:
app: webhook-fetcher
egress:
- hosts:
- "./*" # same namespace services
- "istio-system/*" # telemetry
- "~/*" # only the ServiceEntry hosts above for external
Step 5 — Harden Cloud Metadata Endpoints (AWS IMDSv2)
Require IMDSv2 on every EC2 instance, which mandates a session-oriented token header and a maximum hop limit of 1, making it unreachable via SSRF from an application running in the same instance:
# AWS CloudFormation snippet — enforce IMDSv2 at launch
LaunchTemplate:
Properties:
MetadataOptions:
HttpTokens: required # IMDSv2 only; "optional" allows v1 fallback
HttpPutResponseHopLimit: 1 # Prevents containers from reaching the host IMDS
HttpEndpoint: enabled
# Enforce IMDSv2 on a running instance via AWS CLI
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc1234567890def \
--http-tokens required \
--http-put-response-hop-limit 1
For detailed per-provider allowlist configuration and GCP/Azure metadata hardening, see SSRF allowlists and metadata service protection.
Edge Cases & Bypass Patterns
DNS Rebinding (TOCTOU)
The classic bypass: attacker registers evil.example.com with a short TTL. During pre-flight validation it resolves to a public IP; the validator passes. Before the TCP connection opens, the attacker changes the DNS record to 192.168.1.1. The connection reaches an internal host.
Fix: Use the pinned-IP dialer pattern from Step 3. Alternatively, route all outbound traffic through a proxy that resolves DNS once and locks the connection to that IP.
IPv6 and IPv4-Mapped Address Bypass
A validator that only checks the IPv4 blocklist passes ::ffff:127.0.0.1 (IPv4-mapped IPv6 loopback), 0::ffff:10.0.0.1 (mapped private), or ::1 (IPv6 loopback). The OS resolves these to internal addresses regardless.
Fix: After resolving an IPv6 address, call .ipv4_mapped in Python or ip.To4() in Go to normalise it before blocklist comparison, as shown in Steps 2 and 3 above.
Redirect Chains
An allowlisted URL returns a 301 Location: http://169.254.169.254/latest/meta-data/. HTTP clients follow redirects by default, making the redirect target effectively bypassing the pre-flight check on the original URL.
Fix: Disable automatic redirect following (allow_redirects=False in Python requests, CheckRedirect: func(...) error { return http.ErrUseLastResponse } in Go). If redirects are required by the use case, re-validate the Location header URL through the full validation pipeline before following it.
Protocol Smuggling via Scheme Override
Some URL parsers accept HTTPS://, Http://, or percent-encoded schemes (ht%74ps://). A validator checking parsed.scheme == 'https' on the raw string may be fooled.
Fix: Lower-case and normalise the URL through a standards-compliant parser (Node.js URL, Python urllib.parse, Go net/url) before any comparison. These parsers normalise scheme case by specification.
Open Redirect Chaining (Cross-Origin Forwarding)
An application with an open redirect vulnerability (/redirect?to=<url>) can be chained with SSRF: the SSRF target is the redirect endpoint, which forwards to an internal address. The pre-flight validator sees a same-origin URL and passes.
Fix: Eliminate open redirects (covered under secure HTTP header configuration). Apply the same URL validation on the final destination of any redirect, not only the initial input. Enforcing trust boundaries across internal redirect flows is equally important.
URL Fragment and Query-String Confusion
Parser inconsistencies between the application and underlying HTTP library can lead to different URL interpretations. A URL like https://[email protected]/path passes a validator checking only the hostname field of a naive parser, but some libraries resolve it as a request to 192.168.1.1 with Host: public.example.com.
Fix: Reject any URL containing @ in the authority component. Validate both parsed.hostname and the raw authority for unexpected characters.
Automated Testing & CI Validation
Unit Tests for the Secure Fetch Wrapper
import pytest
from your_module import secure_fetch, resolve_and_validate
BLOCKED_URLS = [
"http://169.254.169.254/latest/meta-data/",
"http://127.0.0.1/admin",
"http://10.0.0.1:8080/internal",
"http://192.168.1.100/secrets",
"gopher://internal-host:70/payload",
"file:///etc/passwd",
"https://[::1]/",
]
@pytest.mark.parametrize("url", BLOCKED_URLS)
def test_blocked_url_raises(url: str):
with pytest.raises(ValueError):
secure_fetch(url)
def test_ipv4_mapped_ipv6_blocked():
"""::ffff:127.0.0.1 must be normalised and blocked."""
with pytest.raises(ValueError, match="blocked range"):
resolve_and_validate("0000:0000:0000:0000:0000:ffff:7f00:0001")
def test_redirect_not_followed(requests_mock):
"""Ensure 301 to internal address is not followed."""
requests_mock.get(
"https://public.example.com/path",
status_code=301,
headers={"Location": "http://192.168.1.100/secret"},
)
response = secure_fetch("https://public.example.com/path")
assert response.status_code == 301
# Must NOT have fetched the redirect target
assert "secret" not in response.text
CI Gate (GitHub Actions)
name: SSRF Security Tests
on:
push:
branches: [main, "feature/**"]
pull_request:
jobs:
ssrf-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-mock requests-mock
- name: Run SSRF unit tests
run: pytest tests/test_ssrf_prevention.py -v --tb=short
- name: OWASP ZAP SSRF scan (baseline)
uses: zaproxy/action-[email protected]
with:
target: ${{ secrets.STAGING_URL }}
rules_file_name: ".zap/ssrf-rules.tsv"
fail_action: true
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| OWASP ASVS 5.2.6 | Verify URL is restricted to permitted schemes | Step 1: scheme allowlist in URL parser |
| OWASP ASVS 5.2.7 | Verify host resolution is blocked for internal ranges | Step 2: pre-flight DNS + IP blocklist |
| OWASP ASVS 5.2.8 | Prevent SSRF via DNS rebinding | Step 3: pinned-IP dialer |
| OWASP Top 10 A10:2021 | Server-Side Request Forgery (SSRF) | All steps combined |
| NIST SP 800-53 SI-10 | Information Input Validation | Steps 1–2: scheme and IP validation |
| NIST SP 800-53 SC-7 | Boundary Protection | Steps 4–5: NetworkPolicy, egress proxy |
| SOC 2 CC6.6 | Logical access controls / boundary protection | Step 4: egress controls; Step 5: IMDSv2 |
| PCI DSS 6.3.2 | Secure coding practices for custom software | Steps 1–3: application-layer validation |
| PCI DSS 6.4.1 | Network segmentation between environments | Step 4: Kubernetes NetworkPolicy |
Audit evidence to collect: structured logs capturing timestamp, source_ip, requested_url, resolved_ip, validation_result, and http_status for every outbound request; SIEM alert rules triggering on requests resolving to RFC 1918 or link-local ranges; WAF/egress proxy rule exports demonstrating blocked SSRF payloads; CI gate results from the test suite above.
Common Pitfalls Checklist
Frequently Asked Questions
Why is URL validation alone insufficient for SSRF prevention?
Attackers routinely bypass string-level validation using DNS rebinding, IPv6 encoding (::ffff:127.0.0.1), or protocol smuggling (gopher://, percent-encoded schemes). The validation and the actual TCP connection are separate events, and anything that changes between them — including DNS TTL expiry — can be weaponised. Defence requires DNS pre-resolution, IP pinning, and a network-layer egress control that is independent of application code.
How do cloud metadata services increase SSRF risk?
Cloud instances expose IAM credentials, SSH keys, and internal routing tables via link-local metadata endpoints reachable at 169.254.169.254 on AWS, GCP, and Azure. A single SSRF request to the right path returns temporary credentials valid for hours, granting the attacker full control over cloud resources without ever accessing a secret store. IMDSv2 enforcement and hop-limit restriction are non-negotiable for any cloud workload that accepts user-supplied URLs.
When should I use an egress proxy instead of in-process IP validation?
Both. Use in-process validation as the first line of defence — it is fast, deterministic, and catches malformed input before it touches the network. Use an egress proxy (Squid, Envoy, or a cloud-native gateway) as the second line of defence in case a bug in the application validator is introduced over time. A service mesh EgressGateway provides a third layer with policy-as-code auditability. Reviewing attack surface mapping techniques helps identify which outbound call paths need all three layers versus which need only two.
How do I handle legitimate use cases that require fetching user-supplied URLs?
Maintain an explicit domain allowlist rather than a blocklist. Accept only URLs whose final resolved hostname matches an entry in the allowlist (e.g., a known set of webhook providers). For open-ended use cases (e.g., social media link previews), run the fetch in a sandboxed process with no access to internal network segments — use a separate Cloud Run service or Lambda with a dedicated VPC that has no route to private subnets. This applies trust boundary isolation at the infrastructure level, not just the code level.
Related
- SSRF Allowlists and Metadata Service Protection — deep-dive on per-provider allowlist configuration and IMDSv2 enforcement
- Cross-Site Request Forgery (CSRF) Defense — client-side request forgery mitigations and the double-submit cookie pattern
- Injection Attack Prevention — parameterized queries and input validation patterns that complement SSRF defences
- Cross-Site Scripting (XSS) Mitigation — DOM and reflected XSS controls; shares input-validation principles with SSRF
- Attack Surface Mapping Techniques — enumerate outbound call paths to identify all SSRF entry points
- Vulnerability Patterns & Web Mitigation Strategies ↑ parent section