T-HUB/packages/egress/src/policy.js
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

93 lines
No EOL
4.3 KiB
JavaScript

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