- Published on
SSRF Prevention — Blocking Server-Side Request Forgery Before It Drains Your Cloud Metadata
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
SSRF (Server-Side Request Forgery) attacks trick your backend into making requests on behalf of attackers. In cloud environments, this is catastrophic—a single SSRF to 169.254.169.254/latest/meta-data/ (AWS metadata endpoint) can steal IAM credentials, secrets, and instance tokens that grant access to the entire cloud account.
This post covers SSRF fundamentals, URL validation with allowlists, blocking internal IP ranges, DNS rebinding defense, and AWS IMDSv2 hardening.
- SSRF Attack Vectors
- URL Validation with Allowlist
- Blocking Internal IP Ranges
- DNS Rebinding Protection
- Safe HTTP Client with Redirect Validation
- AWS IMDSv2 Hardening
- Testing SSRF with Burp Suite
- Checklist
- Conclusion
SSRF Attack Vectors
Common SSRF entry points:
import express from 'express';
import axios from 'axios';
// VULNERABLE: Directly fetches user-supplied URL
app.get('/api/proxy', async (req: express.Request, res: express.Response) => {
const targetUrl = req.query.url as string;
try {
const response = await axios.get(targetUrl);
res.json({ data: response.data });
} catch (error) {
res.status(500).json({ error: 'Fetch failed' });
}
});
// Attack payloads:
// GET /api/proxy?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
// GET /api/proxy?url=http://localhost:8080/admin
// GET /api/proxy?url=http://internal-db:5432
// GET /api/proxy?url=http://192.168.1.100/router/settings
URL Validation with Allowlist
Never use denylist—use explicit allowlist:
import { URL } from 'url';
import axios from 'axios';
interface URLValidationConfig {
allowedHosts: string[];
allowedProtocols: string[];
allowLocalhost: boolean;
}
class SSRFValidator {
private allowedHosts: Set<string>;
private allowedProtocols: Set<string>;
private allowLocalhost: boolean;
constructor(config: URLValidationConfig) {
this.allowedHosts = new Set(config.allowedHosts);
this.allowedProtocols = new Set(config.allowedProtocols);
this.allowLocalhost = config.allowLocalhost;
}
validateURL(urlString: string): boolean {
try {
const url = new URL(urlString);
// Check protocol
if (!this.allowedProtocols.has(url.protocol)) {
console.warn(`Invalid protocol: ${url.protocol}`);
return false;
}
// Check hostname
if (!this.isHostAllowed(url.hostname)) {
console.warn(`Host not allowed: ${url.hostname}`);
return false;
}
// Check port (only allow standard ports)
const standardPorts: Record<string, number> = {
'http:': 80,
'https:': 443,
};
const port = url.port ? parseInt(url.port) : standardPorts[url.protocol];
if (url.port && !this.isStandardPort(url.hostname, parseInt(url.port))) {
console.warn(`Non-standard port: ${url.port}`);
return false;
}
return true;
} catch (error) {
console.warn(`Invalid URL: ${urlString}`);
return false;
}
}
private isHostAllowed(hostname: string): boolean {
// Reject localhost unless explicitly allowed
if (this.isLocalhost(hostname) && !this.allowLocalhost) {
return false;
}
// Check allowlist
return this.allowedHosts.has(hostname);
}
private isLocalhost(hostname: string): boolean {
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1'
);
}
private isStandardPort(hostname: string, port: number): boolean {
const standardPorts = [80, 443];
return standardPorts.includes(port);
}
}
const validator = new SSRFValidator({
allowedHosts: [
'example.com',
'api.partner.com',
'cdn.example.com',
],
allowedProtocols: ['https:'],
allowLocalhost: false,
});
// Secure endpoint
app.get(
'/api/fetch',
async (req: express.Request, res: express.Response) => {
const targetUrl = req.query.url as string;
if (!validator.validateURL(targetUrl)) {
res.status(400).json({ error: 'URL not allowed' });
return;
}
try {
const response = await axios.get(targetUrl, {
timeout: 5000,
});
res.json({ data: response.data });
} catch (error) {
res.status(500).json({ error: 'Fetch failed' });
}
}
);
Blocking Internal IP Ranges
Prevent access to private IP addresses:
import ipaddr from 'ipaddr.js';
class IPValidator {
private blockedRanges = [
// Private IPv4
{ start: '10.0.0.0', end: '10.255.255.255' },
{ start: '172.16.0.0', end: '172.31.255.255' },
{ start: '192.168.0.0', end: '192.168.255.255' },
// Loopback
{ start: '127.0.0.0', end: '127.255.255.255' },
// Link-local
{ start: '169.254.0.0', end: '169.254.255.255' },
// Multicast
{ start: '224.0.0.0', end: '239.255.255.255' },
// Broadcast
{ start: '255.255.255.255', end: '255.255.255.255' },
];
isIpBlocked(ip: string): boolean {
try {
const addr = ipaddr.process(ip);
// Reject IPv6 loopback, link-local, etc.
if (addr.kind() === 'ipv6') {
const ipv6 = addr as any;
if (
ipv6.isLoopback() ||
ipv6.isLinkLocal() ||
ipv6.isMulticast()
) {
return true;
}
}
// Check IPv4 private ranges
if (addr.kind() === 'ipv4') {
const ipv4Addr = addr as any;
if (ipv4Addr.isPrivate()) {
return true;
}
}
return false;
} catch {
return true; // Block unparseable IPs
}
}
validateHostname(hostname: string): Promise<boolean> {
return new Promise((resolve) => {
// Resolve hostname to IP
const dns = require('dns');
dns.lookup(hostname, (err: any, ip: string) => {
if (err) {
resolve(false); // Block unresolvable hostnames
return;
}
resolve(!this.isIpBlocked(ip));
});
});
}
}
// Enhanced SSRF validator with IP blocking
class SecureSSRFValidator {
private urlValidator: SSRFValidator;
private ipValidator: IPValidator;
constructor() {
this.urlValidator = new SSRFValidator({
allowedHosts: ['example.com', 'api.partner.com'],
allowedProtocols: ['https:'],
allowLocalhost: false,
});
this.ipValidator = new IPValidator();
}
async validateSecure(urlString: string): Promise<boolean> {
// First pass: URL format validation
if (!this.urlValidator.validateURL(urlString)) {
return false;
}
// Second pass: Resolve and check IP
try {
const url = new URL(urlString);
const isAllowed = await this.ipValidator.validateHostname(
url.hostname
);
if (!isAllowed) {
console.warn(`Blocked private/reserved IP: ${url.hostname}`);
return false;
}
return true;
} catch {
return false;
}
}
}
app.get(
'/api/safe-fetch',
async (req: express.Request, res: express.Response) => {
const validator = new SecureSSRFValidator();
const targetUrl = req.query.url as string;
const isValid = await validator.validateSecure(targetUrl);
if (!isValid) {
res.status(400).json({ error: 'URL not allowed' });
return;
}
// Fetch safely
res.json({ success: true });
}
);
DNS Rebinding Protection
DNS rebinding allows attacker to change IP between validation and fetch:
import axios from 'axios';
import { URL } from 'url';
import dns from 'dns/promises';
class DNSRebindingValidator {
async fetchWithValidation(
urlString: string,
allowlist: string[]
): Promise<any> {
const url = new URL(urlString);
// First DNS lookup (during validation)
const initialIp = await this.resolveHost(url.hostname);
if (this.isPrivateIP(initialIp)) {
throw new Error(
`SSRF attempt: resolved to private IP ${initialIp}`
);
}
// Verify hostname is in allowlist
if (!allowlist.includes(url.hostname)) {
throw new Error(`Hostname not in allowlist: ${url.hostname}`);
}
// Fetch with timeout to prevent rebinding window
const response = await axios.get(urlString, {
timeout: 3000,
httpAgent: {
keepAlive: false,
},
httpsAgent: {
keepAlive: false,
},
});
// Second validation: Check final IP after redirects
const finalIp = await this.resolveHost(url.hostname);
if (finalIp !== initialIp) {
console.warn(
`DNS rebinding detected: ${initialIp} -> ${finalIp}`
);
throw new Error('DNS rebinding detected');
}
return response.data;
}
private async resolveHost(hostname: string): Promise<string> {
try {
const addresses = await dns.resolve4(hostname);
return addresses[0];
} catch {
throw new Error(`DNS resolution failed for ${hostname}`);
}
}
private isPrivateIP(ip: string): boolean {
const octetStrs = ip.split('.');
const octets = octetStrs.map((x) => parseInt(x, 10));
// 10.0.0.0/8
if (octets[0] === 10) return true;
// 172.16.0.0/12
if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) {
return true;
}
// 192.168.0.0/16
if (octets[0] === 192 && octets[1] === 168) return true;
// 169.254.0.0/16 (link-local)
if (octets[0] === 169 && octets[1] === 254) return true;
// 127.0.0.0/8 (loopback)
if (octets[0] === 127) return true;
return false;
}
}
const rebindValidator = new DNSRebindingValidator();
app.get('/api/fetch-secure', async (req: express.Request, res: express.Response) => {
try {
const data = await rebindValidator.fetchWithValidation(
req.query.url as string,
['example.com', 'api.partner.com']
);
res.json({ data });
} catch (error) {
res.status(400).json({
error: (error as Error).message,
});
}
});
Safe HTTP Client with Redirect Validation
import axios, { AxiosRequestConfig } from 'axios';
import { URL } from 'url';
class SafeHttpClient {
private allowedHosts: Set<string>;
private maxRedirects = 3;
constructor(allowedHosts: string[]) {
this.allowedHosts = new Set(allowedHosts);
}
async get(
urlString: string,
config?: AxiosRequestConfig
): Promise<any> {
const initialUrl = new URL(urlString);
if (!this.allowedHosts.has(initialUrl.hostname)) {
throw new Error(
`Host not allowed: ${initialUrl.hostname}`
);
}
// axios follows redirects by default, but we need to validate each
const response = await axios.get(urlString, {
...config,
maxRedirects: 0, // Handle redirects manually
validateStatus: () => true, // Accept all status codes
});
let followUrl = urlString;
let redirectCount = 0;
while (
[301, 302, 303, 307, 308].includes(response.status) &&
redirectCount < this.maxRedirects
) {
const redirectTo = response.headers.location;
const redirectUrl = new URL(
redirectTo,
followUrl
);
if (!this.allowedHosts.has(redirectUrl.hostname)) {
throw new Error(
`Redirect to disallowed host: ${redirectUrl.hostname}`
);
}
followUrl = redirectUrl.toString();
redirectCount++;
const nextResponse = await axios.get(followUrl, {
...config,
maxRedirects: 0,
validateStatus: () => true,
});
Object.assign(response, nextResponse);
}
if (redirectCount >= this.maxRedirects) {
throw new Error('Too many redirects');
}
return response;
}
}
const client = new SafeHttpClient([
'example.com',
'api.partner.com',
]);
app.get('/api/fetch', async (req: express.Request, res: express.Response) => {
try {
const response = await client.get(req.query.url as string, {
timeout: 5000,
});
res.json({ data: response.data });
} catch (error) {
res.status(400).json({
error: (error as Error).message,
});
}
});
AWS IMDSv2 Hardening
Mitigate SSRF to cloud metadata endpoints:
// AWS IMDSv2 token-based access (more resistant to SSRF)
import axios from 'axios';
class IMDSv2Client {
private tokenUrl =
'http://169.254.169.254/latest/api/token';
private metadataUrl =
'http://169.254.169.254/latest/meta-data/';
private tokenTTL = 21600; // 6 hours
async getMetadata(path: string): Promise<any> {
try {
// Get token (required for IMDSv2)
const token = await this.getToken();
const response = await axios.get(
`${this.metadataUrl}${path}`,
{
headers: {
'X-aws-ec2-metadata-token': token,
},
timeout: 2000,
}
);
return response.data;
} catch (error) {
console.error('IMDS fetch failed:', error);
throw error;
}
}
private async getToken(): Promise<string> {
const response = await axios.put(
this.tokenUrl,
'',
{
headers: {
'X-aws-ec2-metadata-token-ttl-seconds':
this.tokenTTL.toString(),
},
timeout: 1000,
}
);
return response.data;
}
}
// Disable IMDSv1 in user data / EC2 instance metadata options
// aws ec2 modify-instance-metadata-options \
// --instance-id i-12345678 \
// --http-tokens required \
// --http-put-response-hop-limit 1
Testing SSRF with Burp Suite
# Burp Collaborator for out-of-band detection
# In Burp Suite Professional:
# 1. Go to Collaborator tab
# 2. Click "Copy to clipboard"
# 3. Use callback URL in SSRF payload
# Manual testing
curl "http://localhost:3000/api/proxy?url=http://169.254.169.254/latest/meta-data/"
# Using XXE/SSRF tools
# Nuclei template for SSRF
# id: ssrf-detection
# requests:
# - raw:
# - |
# GET /api/proxy?url=http://169.254.169.254/ HTTP/1.1
# Host: target.com
Checklist
- Use explicit allowlist, never denylist, for allowed hosts
- Block private IP ranges (10.x, 172.16.x, 192.168.x, 169.254.x)
- Validate IP after DNS resolution to prevent rebinding
- Handle HTTP redirects with hostname validation
- Enforce HTTPS only
- Set strict timeouts (< 5 seconds)
- Disable IMDSv1 on EC2 instances
- Test SSRF vectors with Burp Collaborator
- Log all SSRF attempts for alerting
- Monitor outbound connections from containers/functions
Conclusion
SSRF prevention requires defense in depth: explicit host allowlisting, IP range blocking, DNS rebinding validation, careful redirect handling, and AWS IMDSv2 hardening. These controls eliminate 99% of SSRF attack paths that target cloud metadata and internal services.