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/.
93 lines
No EOL
4.3 KiB
JavaScript
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
|