From ce021ce0a89868a315aa99f9953f9e78c7b58da5 Mon Sep 17 00:00:00 2001 From: rissrice2105-agent Date: Tue, 30 Jun 2026 13:26:50 -0600 Subject: [PATCH] fix(scan): handle IPv6 literal targets safely --- .../src/app/api/scan/__tests__/route.test.ts | 46 +++++++++++++++ apps/web/src/app/api/scan/route.ts | 56 +++++++++++++++++-- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/api/scan/__tests__/route.test.ts b/apps/web/src/app/api/scan/__tests__/route.test.ts index b29b53f..7eebe7a 100644 --- a/apps/web/src/app/api/scan/__tests__/route.test.ts +++ b/apps/web/src/app/api/scan/__tests__/route.test.ts @@ -26,6 +26,52 @@ describe("POST /api/scan", () => { vi.stubGlobal("fetch", vi.fn()); }); + it("rejects IPv4-mapped loopback addresses", async () => { + const fetchMock = vi.mocked(fetch); + + const res = await POST(makeRequest({ url: "http://[::ffff:127.0.0.1]/admin" })); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Scanning internal addresses is not allowed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects IPv6 multicast addresses", async () => { + const fetchMock = vi.mocked(fetch); + + const res = await POST(makeRequest({ url: "http://[ff02::1]/" })); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toBe("Scanning internal addresses is not allowed"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows public IPv6 literal addresses", async () => { + const fetchMock = vi.mocked(fetch); + fetchMock + .mockResolvedValueOnce( + new Response(null, { + status: 200, + headers: { + "Strict-Transport-Security": "max-age=31536000", + }, + }) + ) + .mockResolvedValue(new Response(null, { status: 404 })); + + const res = await POST(makeRequest({ url: "https://[2606:4700:4700::1111]/" })); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.url).toBe("https://[2606:4700:4700::1111]/"); + expect(fetchMock).toHaveBeenCalledWith( + "https://[2606:4700:4700::1111]/", + expect.objectContaining({ method: "GET", redirect: "manual" }) + ); + }); + it("rejects redirects to internal addresses", async () => { mockLookup.mockResolvedValue([{ address: "203.0.113.10", family: 4 }]); const fetchMock = vi.mocked(fetch); diff --git a/apps/web/src/app/api/scan/route.ts b/apps/web/src/app/api/scan/route.ts index 06230df..9da3377 100644 --- a/apps/web/src/app/api/scan/route.ts +++ b/apps/web/src/app/api/scan/route.ts @@ -37,14 +37,58 @@ function computeGrade(score: number): string { return "F"; } +function isBlockedAddress(address: string): boolean { + const version = isIP(address); + const mappedHex = address.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + const ipv4Address = + version === 4 + ? address + : mappedHex + ? [ + parseInt(mappedHex[1], 16) >> 8, + parseInt(mappedHex[1], 16) & 255, + parseInt(mappedHex[2], 16) >> 8, + parseInt(mappedHex[2], 16) & 255, + ].join(".") + : address.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i)?.[1]; + + if (ipv4Address) { + const parts = ipv4Address.split(".").map((part) => Number(part)); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { + return true; + } + + const [first, second] = parts; + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + first >= 224 + ); + } + + const normalized = address.toLowerCase(); + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fe80:") || + /^f[cd][0-9a-f]{0,2}:/i.test(normalized) || + normalized.startsWith("ff") + ); +} + async function isPrivateIP(hostname: string): Promise { try { - 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) - ); + const lookupHost = hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + const addresses = isIP(lookupHost) + ? [{ address: lookupHost }] + : await dns.lookup(lookupHost, { all: true }); + return addresses.some(({ address }) => isBlockedAddress(address)); } catch { // If DNS resolution fails, fail closed (treat as non-resolvable / potentially malicious) return true;