ThreatCrush SSRF Redirect Guard Patch

Scope: patch bundle for apps/web/src/app/api/scan/route.ts. The scanner already blocked direct private IP targets, but automatic redirects could still move a public scan target to internal addresses such as localhost or cloud metadata. This patch validates every redirect hop before fetching it.

What Changed

Verification

Patch

diff --git a/apps/web/src/__tests__/scan-route.test.ts b/apps/web/src/__tests__/scan-route.test.ts
new file mode 100644
index 0000000..0311cff
--- /dev/null
+++ b/apps/web/src/__tests__/scan-route.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fetchPublicUrl } from "../app/api/scan/route";
+
+describe("scan route SSRF guard", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("rejects private IP targets before fetching", async () => {
+    const fetchMock = vi.spyOn(globalThis, "fetch");
+
+    await expect(fetchPublicUrl("http://127.0.0.1:3000", { method: "GET" })).rejects.toThrow(
+      "Scanning internal addresses is not allowed"
+    );
+
+    expect(fetchMock).not.toHaveBeenCalled();
+  });
+
+  it("rejects redirects from a public URL to an internal address", async () => {
+    const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
+      new Response(null, {
+        status: 302,
+        headers: { location: "http://169.254.169.254/latest/meta-data/" },
+      })
+    );
+
+    await expect(fetchPublicUrl("http://203.0.113.10/scan-me", { method: "GET" })).rejects.toThrow(
+      "Scanning internal addresses is not allowed"
+    );
+
+    expect(fetchMock).toHaveBeenCalledTimes(1);
+  });
+
+  it("allows redirects that stay on public addresses", async () => {
+    const fetchMock = vi
+      .spyOn(globalThis, "fetch")
+      .mockResolvedValueOnce(
+        new Response(null, {
+          status: 302,
+          headers: { location: "http://203.0.113.11/final" },
+        })
+      )
+      .mockResolvedValueOnce(new Response("ok", { status: 200 }));
+
+    const res = await fetchPublicUrl("http://203.0.113.10/start", { method: "GET" });
+
+    expect(res.status).toBe(200);
+    expect(fetchMock).toHaveBeenCalledTimes(2);
+    expect(fetchMock).toHaveBeenLastCalledWith(
+      "http://203.0.113.11/final",
+      expect.objectContaining({ method: "GET", redirect: "manual" })
+    );
+  });
+});
diff --git a/apps/web/src/app/api/scan/route.ts b/apps/web/src/app/api/scan/route.ts
index 99a2666..ef6b21f 100644
--- a/apps/web/src/app/api/scan/route.ts
+++ b/apps/web/src/app/api/scan/route.ts
@@ -37,16 +37,60 @@ function computeGrade(score: number): string {
   return "F";
 }
 
-async function isPrivateIP(hostname: string): Promise<boolean> {
+export async function isPrivateIP(hostname: string): Promise<boolean> {
   try {
     const ip = isIP(hostname) ? hostname : (await dns.lookup(hostname)).address;
     return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|::1|fd[0-9a-f]{2}:)/i.test(ip);
   } catch {
     // If DNS resolution fails, fail closed (treat as non-resolvable / potentially malicious)
-    return true; 
+    return true;
   }
 }
 
+async function assertPublicHttpUrl(url: string): Promise<URL> {
+  const parsed = new URL(url);
+
+  if (!["http:", "https:"].includes(parsed.protocol)) {
+    throw new Error("URL must be http or https");
+  }
+
+  if (await isPrivateIP(parsed.hostname)) {
+    throw new Error("Scanning internal addresses is not allowed");
+  }
+
+  return parsed;
+}
+
+export async function fetchPublicUrl(
+  url: string,
+  init: RequestInit,
+  maxRedirects = 5
+): Promise<Response> {
+  let currentUrl = url;
+
+  for (let redirects = 0; redirects <= maxRedirects; redirects += 1) {
+    await assertPublicHttpUrl(currentUrl);
+
+    const res = await fetch(currentUrl, {
+      ...init,
+      redirect: "manual",
+    });
+
+    if (![301, 302, 303, 307, 308].includes(res.status)) {
+      return res;
+    }
+
+    const location = res.headers.get("location");
+    if (!location) {
+      return res;
+    }
+
+    currentUrl = new URL(location, currentUrl).toString();
+  }
+
+  throw new Error("Too many redirects");
+}
+
 /**
  * POST /api/scan
  * Free security header scanner — no auth required.
@@ -71,27 +115,24 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
   }
 
-  if (!["http:", "https:"].includes(parsedUrl.protocol)) {
-    return NextResponse.json({ error: "URL must be http or https" }, { status: 400 });
+  try {
+    await assertPublicHttpUrl(url);
+  } catch (err) {
+    const message = err instanceof Error ? err.message : "Invalid URL";
+    return NextResponse.json({ error: message }, { status: 400 });
   }
 
-  if (await isPrivateIP(parsedUrl.hostname)) {
-    return NextResponse.json({ error: "Scanning internal addresses is not allowed" }, { status: 400 });
-  }
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), 15000);
 
   try {
-    const controller = new AbortController();
-    const timeout = setTimeout(() => controller.abort(), 15000);
-
-    const res = await fetch(url, {
+    const res = await fetchPublicUrl(url, {
       method: "GET",
       headers: {
         "User-Agent": "Mozilla/5.0 (compatible; ThreatCrush-Scanner/1.0; +https://threatcrush.com)",
       },
       signal: controller.signal,
-      redirect: "follow",
     });
-    clearTimeout(timeout);
 
     const ssl = parsedUrl.protocol === "https:";
 
@@ -156,16 +197,17 @@ export async function POST(request: NextRequest) {
   } catch (err) {
     const message = err instanceof Error ? err.message : "Failed to scan URL";
     return NextResponse.json({ error: message }, { status: 502 });
+  } finally {
+    clearTimeout(timeout);
   }
 }
 
 async function checkExists(url: string): Promise<boolean> {
   try {
-    const res = await fetch(url, {
+    const res = await fetchPublicUrl(url, {
       method: "HEAD",
       signal: AbortSignal.timeout(5000),
-      redirect: "follow",
-    });
+    }, 3);
     return res.ok;
   } catch {
     return false;
Self-destructs in · 1 view · Keep Forever — $5 · Support · HTMLDrops