Server-Side Request Forgery (SSRF) is a vulnerability class that allows an attacker to make the server initiate HTTP requests to an attacker-specified destination. In on-premises environments, this typically enables internal network scanning and reaching internal services that shouldn’t be externally accessible. In cloud environments, SSRF acquires a critical severity uplift because the cloud instance metadata service — accessible at 169.254.169.254 — often returns credentials that enable full cloud account access to any process that can reach it.
SSRF is OWASP A10:2021, and it remains one of the most reliably impactful web vulnerabilities despite being well-understood for years.
What SSRF Looks Like in Code
SSRF typically manifests in features that make server-side HTTP requests based on user-supplied URLs:
- Webhook configuration: “Send notifications to this URL” features
- URL preview/metadata fetching: Link previews, Open Graph metadata scrapers
- File/image imports by URL: “Import from URL” in document editors, image hosting services
- PDF/screenshot generation: Services that render URLs server-side
- API proxies: Backend services that forward requests to user-supplied endpoints
The vulnerable pattern in Python:
import requests
from flask import Flask, request
app = Flask(__name__)
@app.route('/preview')
def preview():
url = request.args.get('url')
# SSRF: no validation of the URL destination
response = requests.get(url, timeout=5)
return response.text, 200
An attacker calls GET /preview?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ and receives the instance’s temporary AWS credentials — access key, secret key, and session token — in the response body.
The same pattern in Node.js:
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/fetch', async (req, res) => {
const { url } = req.query;
// Vulnerable: no allowlist, no SSRF protection
const response = await axios.get(url);
res.json(response.data);
});
The Cloud Metadata Service Attack Chain
When SSRF is reachable from a cloud compute instance (EC2, Lambda, GKE, Azure VM, Cloud Run), the metadata service becomes the primary target.
AWS EC2 Metadata Service
IMDSv1 (legacy, still default on older instances):
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ Returns: "my-instance-role"
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/my-instance-role
→ Returns:
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIA...",
"SecretAccessKey": "...",
"Token": "...",
"Expiration": "2026-07-16T22:00:00Z"
}
The returned credentials work identically to user-created access keys and can be used immediately from any location with API access.
IMDSv2 (requires session token, not bypassable with SSRF):
# IMDSv2 requires a PUT to get a session token first
PUT http://169.254.169.254/latest/api/token
Header: X-aws-ec2-metadata-token-ttl-seconds: 21600
→ Returns: TOKEN_VALUE
GET http://169.254.169.254/latest/meta-data/iam/security-credentials/
Header: X-aws-ec2-metadata-token: TOKEN_VALUE
SSRF using a simple GET cannot obtain the IMDSv2 token because the PUT request requires a non-standard HTTP method. Standard SSRF via URL-fetching libraries is blocked by IMDSv2. However, SSRF via libraries that support arbitrary HTTP methods (axios with method: 'put', the Python requests library, etc.) can still complete the IMDSv2 flow if the attacker controls both the method and the headers of the SSRF request.
GCP Metadata Service
GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
Header: Metadata-Flavor: Google
→ Returns access token for the instance service account
GCP’s metadata service requires the Metadata-Flavor: Google header, which adds a small barrier but does not prevent SSRF exploitation when the attacker can set headers.
Azure IMDS
GET http://169.254.169.254/metadata/instance?api-version=2021-02-01
Header: Metadata: true
→ Returns instance metadata and subscription details
GET http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
Header: Metadata: true
→ Returns access token for the managed identity
Blind SSRF
Blind SSRF occurs when the server makes the requested HTTP call but doesn’t return the response to the attacker. This is harder to exploit for data theft but still valuable for:
- Internal network mapping (which internal IPs are reachable?)
- Port scanning (which ports respond?)
- Triggering server-side operations (CSRF via SSRF)
Detection uses out-of-band channels: the attacker submits a URL pointing to a domain they control (using tools like Burp Collaborator or interactsh) and monitors for DNS lookups and HTTP requests.
# Detecting blind SSRF in a webhook feature
# Attacker-controlled server logs incoming request from the victim server
# Attacker payload: https://attacker-collaborator.net/ssrf-probe
# Server-side code (victim):
async def register_webhook(webhook_url: str):
# Validate webhook before registering -- but no SSRF protection
try:
response = requests.head(webhook_url, timeout=3)
if response.status_code == 200:
save_webhook(webhook_url)
except:
pass # Ignore errors -- this is where blind SSRF hides
Prevention
1. Allowlist-Only URL Validation
The most robust SSRF prevention: only permit URLs to known-good destinations. For webhook features that need to send to arbitrary user-specified URLs, this is difficult to implement completely, but the following controls significantly reduce risk:
import ipaddress
import socket
from urllib.parse import urlparse
from typing import Optional
BLOCKED_PRIVATE_RANGES = [
ipaddress.IPv4Network('10.0.0.0/8'),
ipaddress.IPv4Network('172.16.0.0/12'),
ipaddress.IPv4Network('192.168.0.0/16'),
ipaddress.IPv4Network('127.0.0.0/8'),
ipaddress.IPv4Network('169.254.0.0/16'), # Link-local / metadata service
ipaddress.IPv4Network('100.64.0.0/10'), # Shared address space
]
def is_safe_url(url: str) -> bool:
parsed = urlparse(url)
# Require HTTPS
if parsed.scheme != 'https':
return False
# Resolve hostname to IP
try:
resolved_ip = socket.gethostbyname(parsed.hostname)
except socket.gaierror:
return False
# Check against blocked ranges
ip = ipaddress.IPv4Address(resolved_ip)
for blocked_range in BLOCKED_PRIVATE_RANGES:
if ip in blocked_range:
return False
return True
Critical caveat: DNS rebinding attacks can bypass this check. After resolution passes, the DNS entry changes to point at an internal IP before the actual request. DNS rebinding defences require either re-resolving before the request and rejecting changes, or using a forward proxy that enforces IP-level blocking.
2. Enforce IMDSv2 on All EC2 Instances
# Require IMDSv2 -- blocks basic SSRF from reaching metadata
aws ec2 modify-instance-metadata-options \
--instance-id i-1234567890abcdef0 \
--http-tokens required \
--http-endpoint enabled
# For new instances via Terraform
resource "aws_instance" "app" {
metadata_options {
http_tokens = "required" # IMDSv2 only
http_endpoint = "enabled"
http_hop_limit = 1 # Prevents container SSRF reaching host metadata
}
}
The http_hop_limit = 1 is particularly important for containerised workloads: setting the hop limit to 1 means a request from inside a container (which adds a network hop) cannot reach the metadata service even if the container is compromised.
3. Network-Level Blocking
For cloud workloads where SSRF risk is high, block outbound access to metadata service IPs at the network layer as defence-in-depth:
# Block metadata service from application subnets (AWS, Azure)
# In a security group egress rule -- deny 169.254.169.254/32
# In iptables (for VM-level control)
iptables -A OUTPUT -d 169.254.169.254 -j DROP
iptables -A OUTPUT -d metadata.google.internal -j DROP
4. Use a Fetch Proxy for External URL Requests
Route all server-side URL fetching through a dedicated proxy service that enforces SSRF protections. This centralises the validation logic and prevents individual services from implementing it inconsistently:
// Safe fetch via hardened proxy
const { safeFetch } = require('./ssrf-safe-proxy');
app.get('/preview', async (req, res) => {
const { url } = req.query;
try {
// Proxy validates: HTTPS only, external IPs only, allowlisted content types
const response = await safeFetch(url, {
allowedSchemes: ['https'],
blockPrivateIPs: true,
maxRedirects: 2,
timeout: 5000,
});
res.json({ title: extractTitle(response.body) });
} catch (e) {
res.status(400).json({ error: 'URL not permitted' });
}
});
5. Go: Safe HTTP Client with SSRF Protection
package ssrf
import (
"fmt"
"net"
"net/http"
"time"
)
// SafeDialer blocks connections to private and metadata service IPs
func SafeDialer() *net.Dialer {
return &net.Dialer{
Timeout: 10 * time.Second,
Control: func(network, address, _ string) error {
host, _, _ := net.SplitHostPort(address)
ip := net.ParseIP(host)
if ip == nil {
return nil
}
privateRanges := []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",
"100.64.0.0/10",
"::1/128",
"fc00::/7",
}
for _, cidr := range privateRanges {
_, ipNet, _ := net.ParseCIDR(cidr)
if ipNet.Contains(ip) {
return fmt.Errorf("blocked: address %s is in private range %s", ip, cidr)
}
}
return nil
},
}
}
func NewSafeHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: SafeDialer().DialContext,
}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
}
Testing for SSRF
In security reviews and penetration tests, SSRF test cases should cover:
- Direct IP:
http://169.254.169.254/latest/meta-data/ - URL encoding:
http://169%2E254%2E169%2E254/ - IPv6:
http://[::ffff:169.254.169.254]/ - Decimal IP:
http://2852039166/(169.254.169.254 in decimal) - Octal:
http://0251.0376.0251.0376/ - DNS redirect to 169.254.169.254 via attacker-controlled domain
- Internal hostname:
http://internal-api.corp.local/ - Protocol alternatives:
file:///etc/passwd,dict://,gopher://
All of these should be blocked by a robust SSRF prevention layer.