# SSRF prevention on outbound requests

- **Pattern:** `ab-002397` (`security-hardening.input-validation.ssrf-prevention`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002397
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002397)

## Why it matters

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.

## Severity rationale

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.

## Remediation

Resolve the hostname and validate the IP before making any outbound request to a user-supplied URL:

```ts
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.

## Detection

- **ID:** `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:

  ```ts
  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)
  }
  ```

## External references

- cwe CWE-918
- owasp A10
- nist SI-10

Taxons: injection-and-input-trust

HTML version: https://auditbuffet.com/patterns/ab-002397
