Skip to content

SSRF Defense — Outbound Allowlist + IP Validation

Defends against

Server-Side Request Forgery (SSRF) — the CTI reports uploader resolving an external reference embedded in an uploaded SVG (POST /cti, cover_image) reaching the internal cloud-metadata endpoint.

Prerequisites

  • Local INSECURE-threat-intel-app stack (Docker setup).
  • You can reach http://localhost:4321/latest/meta-data/iam/security-credentials/cti-uploader-role by embedding it as an SVG <image href> in the uploaded cover_image, triggering the "Found SSRF" flag.
  • Editor open on exercise-files/web-attacks-101/INSECURE-threat-intel-app.

Principle

SSRF abuses a server feature that fetches a URL on the user's behalf (feed preview, webhook, avatar import). Because the request originates from the server, it reaches places the user's browser cannot: loopback, link-local cloud metadata (169.254.169.254), and internal RFC1918 hosts.

The fix is to constrain where the server is allowed to connect:

  1. Parse and validate the URL — scheme and host, before fetching.
  2. Resolve the hostname to IPs and block private/link-local/loopback rangesafter DNS resolution, to defeat names that resolve to internal addresses.
  3. Allowlist the small set of destinations the feature actually needs.
  4. Pin the connection to a vetted IP so DNS cannot change between check and fetch (TOCTOU / DNS rebinding).

Allowlisting beats blocklisting: enumerate the few hosts you do trust, not the infinite set you don't.


Step 1 — Locate the vulnerable fetch

unsafeFetchUrl fetches any string the user supplies, with redirects followed:

```1:22:exercise-files/web-attacks-101/INSECURE-threat-intel-app/src/lib/lab/ssrf.ts /* INTENTIONALLY VULNERABLE — server-side fetch of attacker-controlled URL (SSRF). / export async function unsafeFetchUrl(url: string): Promise<{ ok: boolean; status: number; contentType: string; body: string; }> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8000); try { const res = await fetch(url, { // VULNERABLE: fetches ANY user-supplied URL signal: controller.signal, redirect: "follow", // VULNERABLE: follows redirects to internal hosts headers: { "User-Agent": "INSECURE-threat-intel-lab/1.0" }, }); const contentType = res.headers.get("content-type") ?? "unknown"; const body = (await res.text()).slice(0, 12000); // VULNERABLE: returns internal response body to the user return { ok: res.ok, status: res.status, contentType, body }; } finally { clearTimeout(timeout); } }

Three problems: no scheme check (`file://`, `gopher://`), no host validation (loopback/metadata allowed), and `redirect: "follow"` lets a public URL bounce to an internal one.

## Step 2 — Validate the URL and block internal ranges

Add a guard that runs **before** the fetch. It resolves the host and rejects any address that lands in a dangerous range:

```ts
// src/lib/lab/ssrf.ts (additions)
import { lookup } from "node:dns/promises";
import net from "node:net";
import ipaddr from "ipaddr.js"; // npm i ipaddr.js

const ALLOWED_SCHEMES = new Set(["http:", "https:"]);

function isPrivateOrReserved(ip: string): boolean {
  const addr = ipaddr.process(ip); // normalizes IPv4-mapped IPv6
  const range = addr.range();
  // Block loopback, link-local (incl. 169.254.169.254 metadata), private, etc.
  return ["loopback", "linkLocal", "private", "uniqueLocal",
          "reserved", "unspecified", "broadcast", "carrierGradeNat"].includes(range);
}

/** Resolve the host and ensure no resolved IP is internal. Returns a vetted IP. */
async function assertSafeHost(hostname: string): Promise<string> {
  if (net.isIP(hostname)) {
    if (isPrivateOrReserved(hostname)) throw new Error("Destination not allowed");
    return hostname;
  }
  const results = await lookup(hostname, { all: true });
  if (results.length === 0) throw new Error("Host did not resolve");
  for (const { address } of results) {
    if (isPrivateOrReserved(address)) throw new Error("Destination not allowed");
  }
  return results[0].address; // pin to a vetted IP (see Step 4)
}

Step 3 — Allowlist destinations (preferred)

Validation alone is good; an allowlist is better when the feature only ever needs known feed hosts. Combine both — allowlist first, range-check as backstop:

const FEED_HOST_ALLOWLIST = new Set([
  "feeds.example.com",
  "threat.example.org",
]);

export async function safeFetchFeed(rawUrl: string): Promise<{
  ok: boolean; status: number; contentType: string; body: string;
}> {
  let url: URL;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new Error("Invalid URL");
  }

  if (!ALLOWED_SCHEMES.has(url.protocol)) throw new Error("Only http(s) is allowed");
  if (!FEED_HOST_ALLOWLIST.has(url.hostname)) throw new Error("Host not on feed allowlist");

  // Backstop even allowlisted hosts against rebinding to internal IPs.
  await assertSafeHost(url.hostname);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 8000);
  try {
    const res = await fetch(url, {
      signal: controller.signal,
      redirect: "error",                 // do NOT follow redirects to internal hosts
      headers: { "User-Agent": "threat-intel-feed-validator/1.0" },
    });
    const contentType = res.headers.get("content-type") ?? "unknown";
    const body = (await res.text()).slice(0, 12000);
    return { ok: res.ok, status: res.status, contentType, body };
  } finally {
    clearTimeout(timeout);
  }
}

What changed versus the unsafe version:

  • Scheme allowlistfile://, gopher://, dict:// rejected.
  • Host allowlist — only known feed providers.
  • redirect: "error" — a 302 to http://169.254.169.254/… now fails instead of being followed.
  • Resolved-IP range check — a hostname that resolves to 127.0.0.1 or 169.254.169.254 is rejected.

Step 4 — Close the DNS-rebinding gap (production note)

There is a Time-Of-Check/Time-Of-Use window: fetch performs its own DNS lookup, so an attacker domain could resolve to a safe IP during assertSafeHost and to an internal IP microseconds later during fetch. To fully close it, connect to the vetted IP and carry the original Host header (or use an HTTP agent with a pinned lookup):

// Conceptual: connect to the IP you validated, keep Host for TLS/vhosts.
const safeIp = await assertSafeHost(url.hostname);
const agent = new https.Agent({ lookup: (_h, _o, cb) => cb(null, safeIp, net.isIPv6(safeIp) ? 6 : 4) });

For the WA101 lab, Steps 2–3 are sufficient to stop the exploit; this step is the production-grade hardening worth knowing.

Step 5 — Point the page at the safe fetcher

In src/pages/index.astro, swap the import/call:

import { safeFetchFeed } from '../lib/lab/ssrf';
// ...
feedResult = await safeFetchFeed(feedUrl);

The try/catch already surfaces the thrown "Destination not allowed" as feedError.

Step 6 — Verify the fix

npm run docker:down && npm run docker:up
Embedded SVG <image href> value Before After (fixed)
https://cdn.example.com/logo.png (allowlisted) Fetched Fetched
https://example.com (not allowlisted) Fetched "Host not on avatar allowlist"
http://localhost:4321/latest/meta-data/iam/security-credentials/cti-uploader-role IMDS JSON returned "Host not on avatar allowlist" / "Destination not allowed"
http://169.254.169.254/latest/meta-data/ (would hit metadata in cloud) Blocked by range check
file:///etc/passwd scheme honored "Only http(s) is allowed"

The "Found SSRF" flag (detectSsrfResponse) fires when the simulated IMDS JSON (AKIALABEXAMPLE) appears in the resolved-resource preview. Once embedded references are no longer resolved (or internal hosts are blocked), that body is never returned and the flag stops firing.

Confirm in Burp Repeater that re-uploading the SVG via POST /cti (cover_image) with href=http://localhost:4321/latest/meta-data/iam/security-credentials/cti-uploader-role now returns the error message instead of fake credentials.


Defense in depth

  • Allowlist outbound destinations; treat blocklists as backstops only.
  • Validate after DNS resolution and block loopback, link-local (169.254.0.0/16), private (10/8, 172.16/12, 192.168/16), unique-local IPv6, and CGNAT ranges.
  • Disable redirects (or re-validate each hop) on user-driven fetches.
  • Restrict schemes to http(s).
  • Network egress controls: run fetchers in a segment with no route to metadata/internal services; in AWS, enforce IMDSv2 (token-bound) so a bare GET to 169.254.169.254 fails.
  • Drop the response body when the feature only needs status — returning fetched content to the user amplifies SSRF into data exfiltration.

Verification checklist

  • [ ] User URLs are parsed and scheme-checked before fetching.
  • [ ] Hostnames are resolved and internal/reserved IP ranges are rejected.
  • [ ] Destinations are constrained by an allowlist where feasible.
  • [ ] Redirects are not blindly followed to new hosts.
  • [ ] Loopback and 169.254.169.254 requests are blocked.
  • [ ] The "Found SSRF" flag no longer appears.