Add SSRF URL validator for clean-pdf
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user