120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
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);
|
|
}
|
|
|
|
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
|
|
function expandV6(ip: string): number[] | null {
|
|
let s = ip.toLowerCase();
|
|
|
|
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
|
|
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
if (dotted) {
|
|
if (isIP(dotted[1]) !== 4) return null;
|
|
const n = v4ToInt(dotted[1]);
|
|
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
|
|
}
|
|
|
|
const halves = s.split("::");
|
|
if (halves.length > 2) return null;
|
|
const head = halves[0] ? halves[0].split(":") : [];
|
|
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
|
|
|
|
let groups: string[];
|
|
if (halves.length === 2) {
|
|
const missing = 8 - head.length - tail.length;
|
|
if (missing < 0) return null;
|
|
groups = [...head, ...Array(missing).fill("0"), ...tail];
|
|
} else {
|
|
groups = head;
|
|
}
|
|
if (groups.length !== 8) return null;
|
|
|
|
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
|
|
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
|
|
return nums;
|
|
}
|
|
|
|
export function isPrivateAddress(ip: string): boolean {
|
|
if (isIP(ip) === 4) return isPrivateV4(ip);
|
|
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
|
|
|
|
const groups = expandV6(ip);
|
|
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
|
|
|
|
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
|
|
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
|
|
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
|
|
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
|
|
const v4num = groups[6] * 0x10000 + groups[7];
|
|
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
|
|
return isPrivateV4(dottedV4);
|
|
}
|
|
|
|
const first = groups[0];
|
|
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
|
|
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
|
|
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, "").replace(/\.$/, ""); // [::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;
|
|
}
|