Server-side request forgery (CWE-918, OWASP A10 Server-Side Request Forgery) lets attackers weaponize your application's outbound HTTP client to probe or attack internal services — cloud metadata endpoints (169.254.169.254), internal databases, and private admin APIs that are not exposed to the internet. Any endpoint that fetches a user-supplied URL without validation is an SSRF vector. NIST 800-53 SI-10 requires input validation on all externally supplied values. In cloud environments, SSRF against the EC2 metadata endpoint can yield IAM credentials, giving an attacker full AWS account access.
Medium because SSRF requires a specific feature (outbound URL fetch) but can escalate to full cloud credential theft via the metadata service on major platforms.
Resolve the hostname and validate the IP before making any outbound request to a user-supplied URL:
import dns from 'dns/promises'
import { isPrivateIp } from 'private-ip'
async function safeFetch(userUrl: string): Promise<Response> {
const parsed = new URL(userUrl) // throws on invalid URL
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP/HTTPS allowed')
}
const addresses = await dns.resolve4(parsed.hostname).catch(() => [])
for (const addr of addresses) {
if (isPrivateIp(addr)) throw new Error('Private IP addresses are blocked')
}
return fetch(userUrl)
}
For webhook URL registration, prefer an explicit domain allowlist over blocklist logic — allowlists are easier to reason about and don't require enumerating all private ranges.
ID: security-hardening.input-validation.ssrf-prevention
Severity: medium
What to look for: Enumerate every outbound HTTP request in server-side code (fetch, axios, http.get, etc.). For each, search for fetch(), axios.get(), http.request(), https.request(), or other HTTP client usage where the URL or hostname includes user-provided data (from request body, query params, headers, or database records originally sourced from user input). Check webhook URL handling, avatar/image URL fetching, proxy or preview endpoints, and OAuth redirect_uri handling.
Pass criteria: All outbound HTTP requests to user-supplied URLs validate the target against a blocklist of private IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16) or an explicit allowlist of expected domains — at least URL validation and private IP range blocking on 100% of outbound calls. Report: "X outbound request points found, all Y have SSRF protections."
Fail criteria: The application makes outbound HTTP requests to URLs that are directly or indirectly controlled by user input without validating the target.
Skip (N/A) when: The application makes no outbound HTTP requests that include user-controlled URLs.
Detail on fail: "POST /api/preview fetches user-supplied URL without validating target — internal services accessible via SSRF" or "Webhook registration accepts any URL including private IP ranges"
Remediation: Resolve and validate the target before making the request:
import dns from 'dns/promises'
import { isPrivateIp } from 'private-ip' // npm i private-ip
async function safeFetch(userUrl: string): Promise<Response> {
let parsed: URL
try {
parsed = new URL(userUrl)
} catch {
throw new Error('Invalid URL')
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error('Only HTTP/HTTPS URLs are allowed')
}
// Resolve hostname and check for private IPs
const addresses = await dns.resolve4(parsed.hostname).catch(() => [])
for (const addr of addresses) {
if (isPrivateIp(addr)) {
throw new Error('Requests to private IP addresses are not allowed')
}
}
return fetch(userUrl)
}