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/.
91 lines
4.6 KiB
TypeScript
91 lines
4.6 KiB
TypeScript
import { lookup } from "node:dns/promises";
|
|
import { BlockList, isIP } from "node:net";
|
|
import { domainToASCII } from "node:url";
|
|
import { EgressError } from "./errors.js";
|
|
|
|
export interface ResolvedAddress { address: string; family: number }
|
|
export type Resolver = (hostname: string) => Promise<readonly ResolvedAddress[]>;
|
|
export interface EgressPolicyOptions {
|
|
/** Operator-controlled allowlists; defaults to HTTP 80 and HTTPS 443 only. */
|
|
ports?: Partial<Record<"http:" | "https:", readonly number[]>>;
|
|
resolver?: Resolver;
|
|
}
|
|
export interface ValidatedEndpoint {
|
|
url: URL;
|
|
pinnedAddress: string;
|
|
family: 4 | 6;
|
|
hostKey: string;
|
|
}
|
|
|
|
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],
|
|
] as const) 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],
|
|
] as const) 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: string): void {
|
|
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: URL): string {
|
|
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: string | URL, options: EgressPolicyOptions = {}): URL {
|
|
const text = String(raw);
|
|
if (/[\s\\\u0000-\u001f\u007f]/u.test(text)) throw new EgressError("INVALID_URL", "Malformed URL");
|
|
let url: 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 {
|
|
constructor(private readonly options: EgressPolicyOptions = {}) {}
|
|
validateUrl(raw: string | URL): URL { return validateUrl(raw, this.options); }
|
|
async validate(raw: string | URL): Promise<ValidatedEndpoint> {
|
|
const url = this.validateUrl(raw);
|
|
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
let addresses: readonly ResolvedAddress[];
|
|
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) as 4 | 6, hostKey: hostBudgetKey(url) };
|
|
}
|
|
}
|