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
- Added
fetchPublicUrl, which uses manual redirects and validates each destination URL before following it. - Kept the existing direct private-IP/DNS fail-closed guard.
- Applied the same guarded fetch path to the main scan request and
security.txt/robots.txtchecks. - Added regression coverage for direct private IP, public-to-internal redirect, and public-to-public redirect.
Verification
corepack pnpm --filter @profullstack/threatcrush-web exec vitest run src/__tests__/scan-route.test.ts-> 1 file passed, 3 tests passed.corepack pnpm --filter @profullstack/threatcrush-web exec tsc -p tsconfig.json --noEmit-> passed.- Note: full workspace install with scripts hit a Windows/Node 24 native build issue in
better-sqlite3; package-web install with scripts ignored succeeded and was enough for this route/test 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;