Add SSRF URL validator for clean-pdf

This commit is contained in:
2026-07-06 11:28:11 +05:00
parent 52073fd914
commit 2c619be043
2 changed files with 140 additions and 0 deletions
+82
View File
@@ -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<URL> {
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;
}