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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
93
packages/egress/src/policy.js
Normal file
93
packages/egress/src/policy.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue