From 57009ace69aa36cf702b20a3253153489c23f244 Mon Sep 17 00:00:00 2001 From: rissrice2105-agent Date: Wed, 24 Jun 2026 18:13:44 -0600 Subject: [PATCH] fix(api): block scanner redirects to internal hosts --- .../src/app/api/scan/__tests__/route.test.ts | 79 +++++++++++++++++++ apps/web/src/app/api/scan/route.ts | 47 ++++++++--- 2 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/api/scan/__tests__/route.test.ts diff --git a/apps/web/src/app/api/scan/__tests__/route.test.ts b/apps/web/src/app/api/scan/__tests__/route.test.ts new file mode 100644 index 0000000..b29b53f --- /dev/null +++ b/apps/web/src/app/api/scan/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockLookup } = vi.hoisted(() => ({ + mockLookup: vi.fn(), +})); + +vi.mock("dns/promises", () => ({ + default: { + lookup: mockLookup, + }, +})); + +import { POST } from "@/app/api/scan/route"; + +function makeRequest(body: unknown) { + return new Request("http://localhost/api/scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/scan", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", vi.fn()); + }); + + it("rejects redirects to internal addresses", async () => { + mockLookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "http://127.0.0.1/admin" }, + }) + ); + + const res = await POST(makeRequest({ url: "https://example.com/scan-me" })); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Scanning internal addresses is not allowed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("rejects redirects instead of following them", async () => { + mockLookup + .mockResolvedValueOnce([{ address: "203.0.113.10", family: 4 }]) + .mockResolvedValueOnce([{ address: "203.0.113.11", family: 4 }]) + .mockResolvedValue([{ address: "203.0.113.11", family: 4 }]); + + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce( + new Response(null, { + status: 302, + headers: { location: "https://safe.example/final" }, + }) + ) + .mockResolvedValueOnce( + new Response(null, { + status: 200, + headers: { + "Strict-Transport-Security": "max-age=31536000", + "X-Content-Type-Options": "nosniff", + }, + }) + ) + .mockResolvedValue(new Response(null, { status: 404 })); + + const res = await POST(makeRequest({ url: "https://example.com/scan-me" })); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Redirect responses are not followed by the scanner"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/app/api/scan/route.ts b/apps/web/src/app/api/scan/route.ts index 99a2666..06230df 100644 --- a/apps/web/src/app/api/scan/route.ts +++ b/apps/web/src/app/api/scan/route.ts @@ -39,14 +39,29 @@ function computeGrade(score: number): string { async function isPrivateIP(hostname: string): Promise { 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); + const addresses = isIP(hostname) + ? [{ address: hostname }] + : await dns.lookup(hostname, { all: true }); + return addresses.some(({ address }) => + /^(0\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|::1|fe80:|fd[0-9a-f]{2}:)/i.test(address) + ); } catch { // If DNS resolution fails, fail closed (treat as non-resolvable / potentially malicious) return true; } } +async function validateExternalHttpUrl(url: URL): Promise { + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("URL must be http or https"); + } + if (await isPrivateIP(url.hostname)) { + throw new Error("Scanning internal addresses is not allowed"); + } +} + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + /** * POST /api/scan * Free security header scanner — no auth required. @@ -71,12 +86,10 @@ 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 }); - } - - if (await isPrivateIP(parsedUrl.hostname)) { - return NextResponse.json({ error: "Scanning internal addresses is not allowed" }, { status: 400 }); + try { + await validateExternalHttpUrl(parsedUrl); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 400 }); } try { @@ -89,10 +102,23 @@ export async function POST(request: NextRequest) { "User-Agent": "Mozilla/5.0 (compatible; ThreatCrush-Scanner/1.0; +https://threatcrush.com)", }, signal: controller.signal, - redirect: "follow", + redirect: "manual", }); clearTimeout(timeout); + if (REDIRECT_STATUSES.has(res.status)) { + const location = res.headers.get("location"); + if (location) { + const redirectUrl = new URL(location, parsedUrl); + try { + await validateExternalHttpUrl(redirectUrl); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 400 }); + } + } + return NextResponse.json({ error: "Redirect responses are not followed by the scanner" }, { status: 400 }); + } + const ssl = parsedUrl.protocol === "https:"; // Check security headers @@ -161,10 +187,11 @@ export async function POST(request: NextRequest) { async function checkExists(url: string): Promise { try { + await validateExternalHttpUrl(new URL(url)); const res = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(5000), - redirect: "follow", + redirect: "manual", }); return res.ok; } catch {