Project Files
src / security.ts
import * as dns from "node:dns/promises";
import { isIPv4, isIPv6 } from "node:net";
import { URL } from "node:url";
const BLOCKED_HOSTNAMES = new Set(
[
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata.google.internal",
].map((h) => h.toLowerCase()),
);
export function hostSuffixAllowed(
hostname: string,
allowed: Set<string> | null,
blocked: Set<string> | null,
): boolean {
const h = hostname.toLowerCase().replace(/\.$/, "");
if (blocked) {
for (const suf of blocked) {
const s = suf.toLowerCase();
if (!s) continue;
if (h === s || h.endsWith(`.${s}`)) return false;
}
}
if (!allowed || allowed.size === 0) return true;
for (const suf of allowed) {
const s = suf.toLowerCase();
if (!s) continue;
if (h === s || h.endsWith(`.${s}`)) return true;
}
return false;
}
export function ipIsForbidden(addr: string): boolean {
if (isIPv4(addr)) {
const octets = addr.split(".").map((x) => Number(x));
if (octets.length !== 4 || octets.some((n) => Number.isNaN(n))) return true;
const [a, b] = octets;
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 0 && octets[2] === 0) return true;
if (a === 192 && b === 0 && octets[2] === 2) return true;
if (a === 198 && b === 51 && octets[2] === 100) return true;
if (a === 203 && b === 0 && octets[2] === 113) return true;
if (a >= 224 && a <= 239) return true;
if (a >= 240) return true;
return false;
}
if (isIPv6(addr)) {
const lower = addr.toLowerCase();
if (lower === "::1") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
if (lower.startsWith("ff")) return true;
if (lower === "::") return true;
return false;
}
return true;
}
function parseSuffixCsv(raw: string): Set<string> | null {
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return null;
return new Set(parts);
}
export type FetchPolicy = {
allowPrivateHosts: boolean;
allowedSuffixes: Set<string> | null;
blockedSuffixes: Set<string> | null;
};
export function policyFromConfig(cfg: {
fetchAllowPrivateHosts: boolean;
fetchAllowedHostSuffixes: string;
fetchBlockedHostSuffixes: string;
}): FetchPolicy {
return {
allowPrivateHosts: cfg.fetchAllowPrivateHosts,
allowedSuffixes: parseSuffixCsv(cfg.fetchAllowedHostSuffixes),
blockedSuffixes: parseSuffixCsv(cfg.fetchBlockedHostSuffixes),
};
}
export async function assertUrlSafeForFetch(
urlStr: string,
policy: FetchPolicy,
): Promise<void> {
let parsed: URL;
try {
parsed = new URL(urlStr);
} catch {
throw new Error("Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("Only http and https URLs are allowed");
}
const host = parsed.hostname;
if (!host) throw new Error("URL has no host");
if (BLOCKED_HOSTNAMES.has(host.toLowerCase())) {
throw new Error(`Host not allowed: ${host}`);
}
if (!hostSuffixAllowed(host, policy.allowedSuffixes, policy.blockedSuffixes)) {
throw new Error("Host is not permitted by allowed/blocked host suffix policy");
}
if (isIPv4(host) || isIPv6(host)) {
if (!policy.allowPrivateHosts && ipIsForbidden(host)) {
throw new Error("Target IP address is not allowed");
}
return;
}
if (policy.allowPrivateHosts) return;
const infos = await dns.lookup(host, { all: true, verbatim: true });
if (!infos.length) throw new Error(`Could not resolve host: ${host}`);
for (const info of infos) {
if (ipIsForbidden(info.address)) {
throw new Error(`Host resolves to a forbidden address: ${info.address}`);
}
}
}