import { lookup } from "node:dns/promises"; import { BlockList, isIP } from "node:net"; import { domainToASCII } from "node:url"; import { EgressError } from "./errors.js"; const blocked = new BlockList(); for (const [subnet, prefix] of [ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16], ["192.0.2.0", 24], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4], ]) blocked.addSubnet(subnet, prefix, "ipv4"); for (const [subnet, prefix] of [ ["::", 96], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["2001::", 32], ["2001:db8::", 32], ["2002::", 16], ["64:ff9b::", 96], ["64:ff9b:1::", 48], ]) blocked.addSubnet(subnet, prefix, "ipv6"); // Keep this separate: Node also maps native IPv4 into this range internally. const mapped = new BlockList(); mapped.addSubnet("::ffff:0:0", 96, "ipv6"); export function assertPublicAddress(address) { const normalized = address.replace(/^\[|\]$/g, "").toLowerCase(); const family = isIP(normalized); if (!family) throw new EgressError("DNS_FAILED", "Invalid resolved address"); if (normalized.includes("%") || family === 6 && mapped.check(normalized, "ipv6") || blocked.check(normalized, family === 4 ? "ipv4" : "ipv6")) { throw new EgressError("DENIED_PRIVATE_ADDRESS", "Non-public addresses are denied"); } } export function hostBudgetKey(url) { const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase(); const canonical = isIP(host) ? host : domainToASCII(host); return `${isIP(canonical) === 6 ? `[${canonical}]` : canonical}:${url.port || (url.protocol === "https:" ? "443" : "80")}`; } /** Pure syntactic/literal policy check; no DNS or budget is consumed. */ export function validateUrl(raw, options = {}) { const text = String(raw); if (/[\s\\\u0000-\u001f\u007f]/u.test(text)) throw new EgressError("INVALID_URL", "Malformed URL"); let url; try { url = new URL(text); } catch { throw new EgressError("INVALID_URL", "Malformed URL"); } if (url.protocol !== "http:" && url.protocol !== "https:") throw new EgressError("SCHEME_DENIED", "Only HTTP and HTTPS are allowed"); if (!url.hostname || url.username || url.password) throw new EgressError("INVALID_URL", "URL credentials are denied"); const port = Number(url.port || (url.protocol === "https:" ? 443 : 80)); if (!(options.ports?.[url.protocol] ?? (url.protocol === "https:" ? [443] : [80])).includes(port)) { throw new EgressError("PORT_DENIED", "Port is not allowed for this scheme"); } const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase(); if (host === "localhost" || host.endsWith(".localhost")) throw new EgressError("DENIED_PRIVATE_ADDRESS", "Localhost is denied"); if (isIP(host)) assertPublicAddress(host); else url.hostname = domainToASCII(host); url.hash = ""; return url; } export class EgressPolicy { options; constructor(options = {}) { this.options = options; } validateUrl(raw) { return validateUrl(raw, this.options); } async validate(raw) { const url = this.validateUrl(raw); const host = url.hostname.replace(/^\[|\]$/g, ""); let addresses; try { addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await (this.options.resolver ?? ((name) => lookup(name, { all: true, verbatim: true })))(host); } catch (error) { if (error instanceof EgressError) throw error; throw new EgressError("DNS_FAILED", "Hostname resolution failed"); } if (!addresses.length) throw new EgressError("DNS_FAILED", "No resolved addresses"); // Mixed public/private answers are denied, rather than relying on answer order. for (const entry of addresses) assertPublicAddress(entry.address); const pinnedAddress = addresses[0].address.replace(/^\[|\]$/g, "").toLowerCase(); return { url, pinnedAddress, family: isIP(pinnedAddress), hostKey: hostBudgetKey(url) }; } } //# sourceMappingURL=policy.js.map