Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/web/src/app/api/scan/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
47 changes: 37 additions & 10 deletions apps/web/src/app/api/scan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,29 @@ function computeGrade(score: number): string {

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);
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<void> {
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.
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -161,10 +187,11 @@ export async function POST(request: NextRequest) {

async function checkExists(url: string): Promise<boolean> {
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 {
Expand Down
Loading