Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/web/safety.ts

Raw
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";

export type DnsResolver = (hostname: string) => Promise<string[]>;

export interface SafeUrlOptions {
	resolver?: DnsResolver;
	allowPrivateNetwork?: boolean;
}

export interface SafeUrlResult {
	url: URL;
	normalizedUrl: string;
	addresses: string[];
}

const BLOCKED_SUFFIXES = [".local", ".localhost", ".internal"];

export async function assertSafeHttpUrl(
	input: string | URL,
	options: SafeUrlOptions = {},
): Promise<SafeUrlResult> {
	const url = new URL(input.toString().trim());
	if (url.protocol !== "http:" && url.protocol !== "https:") {
		throw new Error(
			`Only http: and https: URLs are supported, got ${url.protocol}`,
		);
	}
	validateHostname(url, options.allowPrivateNetwork === true);
	if (options.allowPrivateNetwork === true) {
		return { url, normalizedUrl: url.toString(), addresses: [] };
	}
	const hostname = stripIpv6Brackets(url.hostname.toLowerCase());
	if (isIP(hostname) !== 0)
		return { url, normalizedUrl: url.toString(), addresses: [hostname] };
	const addresses = options.resolver
		? await options.resolver(hostname)
		: (await lookup(hostname, { all: true, verbatim: true })).map(
				(x) => x.address,
			);
	for (const address of addresses) {
		if (isPrivateOrReservedIp(address)) {
			throw new Error(
				`Hostname resolves to blocked private address: ${address}`,
			);
		}
	}
	return { url, normalizedUrl: url.toString(), addresses };
}

export async function resolveRedirectUrl(
	fromUrl: string,
	location: string,
	options: SafeUrlOptions = {},
): Promise<SafeUrlResult> {
	return assertSafeHttpUrl(new URL(location, fromUrl), options);
}

function validateHostname(url: URL, allowPrivate: boolean): void {
	const hostname = stripIpv6Brackets(url.hostname.toLowerCase());
	if (!hostname) throw new Error("URL must include a hostname");
	if (
		hostname === "localhost" ||
		BLOCKED_SUFFIXES.some((x) => hostname.endsWith(x))
	) {
		throw new Error(`Blocked private hostname: ${hostname}`);
	}
	if (!allowPrivate && isPrivateOrReservedIp(hostname)) {
		throw new Error(`Blocked private address: ${hostname}`);
	}
}

export function isPrivateOrReservedIp(address: string): boolean {
	const host = stripIpv6Brackets(address.toLowerCase());
	const family = isIP(host);
	if (family === 4) return isPrivateOrReservedIpv4(host);
	if (family === 6) return isPrivateOrReservedIpv6(host);
	return false;
}

function isPrivateOrReservedIpv4(address: string): boolean {
	const [a = 0, b = 0] = address.split(".").map((x) => Number.parseInt(x, 10));
	return (
		a === 0 ||
		a === 10 ||
		a === 127 ||
		(a === 169 && b === 254) ||
		(a === 172 && b >= 16 && b <= 31) ||
		(a === 192 && b === 168) ||
		(a === 100 && b >= 64 && b <= 127) ||
		a >= 224
	);
}

function isPrivateOrReservedIpv6(address: string): boolean {
	const embedded = address.match(
		/(?:::ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/u,
	)?.[1];
	if (embedded) return isPrivateOrReservedIpv4(embedded);
	const first = hextet(address, 0);
	const second = hextet(address, 1);
	const third = hextet(address, 2);
	const fourth = hextet(address, 3);
	return (
		address === "::" ||
		address === "::1" ||
		(first & 0xff00) === 0x0000 ||
		(first === 0x0100 && second === 0 && third === 0 && fourth === 0) ||
		(first & 0xfe00) === 0xfc00 ||
		(first & 0xffc0) === 0xfe80 ||
		(first & 0xff00) === 0xff00 ||
		first === 0x2002 ||
		(first === 0x2001 && second === 0x0db8)
	);
}

function hextet(address: string, index: number): number {
	return Number.parseInt(address.split(":")[index] || "0", 16) || 0;
}

function stripIpv6Brackets(hostname: string): string {
	return hostname.startsWith("[") && hostname.endsWith("]")
		? hostname.slice(1, -1)
		: hostname;
}