From 2c619be043e8b103f88c50340ff7d3f441e45fd6 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 11:28:11 +0500 Subject: [PATCH] Add SSRF URL validator for clean-pdf --- src/lib/clean-pdf/__tests__/ssrf.test.ts | 58 +++++++++++++++++ src/lib/clean-pdf/ssrf.ts | 82 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/lib/clean-pdf/__tests__/ssrf.test.ts create mode 100644 src/lib/clean-pdf/ssrf.ts diff --git a/src/lib/clean-pdf/__tests__/ssrf.test.ts b/src/lib/clean-pdf/__tests__/ssrf.test.ts new file mode 100644 index 0000000..f6fdecc --- /dev/null +++ b/src/lib/clean-pdf/__tests__/ssrf.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf"; + +describe("isPrivateAddress", () => { + const privateIps = [ + "127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254", + "172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1", + "224.0.0.1", "255.255.255.255", + "::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1", + ]; + const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8"]; + + it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true)); + it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false)); +}); + +describe("assertPublicUrl", () => { + const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }]; + const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }]; + const mixedResolve = async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.5", family: 4 }, + ]; + + it("пропускает публичный https-URL", async () => { + const url = await assertPublicUrl("https://example.com/article", publicResolve); + expect(url.hostname).toBe("example.com"); + }); + + it.each([ + "file:///etc/passwd", + "ftp://example.com/x", + "chrome://settings", + "not a url", + ])("блокирует %s", async (raw) => { + await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует localhost и .local без резолва", async () => { + await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует литеральный приватный IP", async () => { + await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => { + await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError); + await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError); + }); + + it("блокирует хост, который не резолвится", async () => { + const failResolve = async () => { throw new Error("ENOTFOUND"); }; + await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError); + }); +}); diff --git a/src/lib/clean-pdf/ssrf.ts b/src/lib/clean-pdf/ssrf.ts new file mode 100644 index 0000000..ecd01c2 --- /dev/null +++ b/src/lib/clean-pdf/ssrf.ts @@ -0,0 +1,82 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; + +export class BlockedUrlError extends Error {} + +export type LookupFn = ( + hostname: string, + opts: { all: true }, +) => Promise<{ address: string; family: number }[]>; + +function v4ToInt(ip: string): number { + return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0); +} + +// [начало включительно, конец включительно] в виде 32-битных чисел +const V4_PRIVATE: Array<[number, number]> = [ + ["0.0.0.0", "0.255.255.255"], // "this network" + ["10.0.0.0", "10.255.255.255"], // RFC1918 + ["100.64.0.0", "100.127.255.255"], // CGNAT + ["127.0.0.0", "127.255.255.255"], // loopback + ["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata + ["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда) + ["192.0.0.0", "192.0.0.255"], // IETF protocol assignments + ["192.168.0.0", "192.168.255.255"], // RFC1918 + ["198.18.0.0", "198.19.255.255"], // benchmarking + ["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast +].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]); + +function isPrivateV4(ip: string): boolean { + const n = v4ToInt(ip); + return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi); +} + +export function isPrivateAddress(ip: string): boolean { + if (isIP(ip) === 4) return isPrivateV4(ip); + if (isIP(ip) !== 6) return true; // не IP — не пропускаем + + const lower = ip.toLowerCase(); + // v4-mapped: ::ffff:10.0.0.1 + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateV4(mapped[1]); + + if (lower === "::" || lower === "::1") return true; + // fc00::/7 (ULA), fe80::/10 (link-local) + const firstGroup = parseInt(lower.split(":")[0] || "0", 16); + if (firstGroup >= 0xfc00 && firstGroup <= 0xfdff) return true; + if (firstGroup >= 0xfe80 && firstGroup <= 0xfebf) return true; + return false; +} + +export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new BlockedUrlError("Некорректный URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new BlockedUrlError("Поддерживаются только http и https"); + } + + const hostname = url.hostname.replace(/^\[|\]$/g, ""); // [::1] → ::1 + if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) { + throw new BlockedUrlError("Адрес недоступен"); + } + + if (isIP(hostname)) { + if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен"); + return url; + } + + let addresses: { address: string; family: number }[]; + try { + addresses = await resolve(hostname, { all: true }); + } catch { + throw new BlockedUrlError("Не удалось определить адрес сайта"); + } + if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) { + throw new BlockedUrlError("Адрес недоступен"); + } + return url; +}