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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { assertMediaCommand, MediaCommandBuilder, type Demuxer } from "./command";
import { DEFAULT_LIMITS, limitsWith, sandboxEnabled, sandboxEnvironment } from "./profile";
import { validateProbe } from "./probe";
const id = "b".repeat(64); const builder = new MediaCommandBuilder();
describe("argv-only media commands", () => {
it("uses minted ids and fixed sandbox paths for probe and transcode", () => {
const probe = builder.probe(id, "mov");
expect(probe.argv).toContain(`file:/upload/${id}`);
expect(probe.argv.slice(probe.argv.indexOf("-protocol_whitelist"), probe.argv.indexOf("-protocol_whitelist") + 4)).toEqual(["-protocol_whitelist", "file", "-f", "mov"]);
expect(builder.transcode(id, "mov", id, { width: 1280, height: 720 }).argv.at(-1)).toBe(`file:/scratch/${id}.mp4`);
expect(Object.isFrozen(probe.argv)).toBe(true);
expect(() => assertMediaCommand({ binary: "ffmpeg", argv: [] })).toThrow(/builder/i);
});
it.each(["filename.mp4", "../escape", "file:/etc/passwd", "concat:a|b", "https://example.com", "rtmp://example.com/key", "a;id", "a\u0000b"])("rejects paths/protocols as asset id: %s", value => {
expect(() => builder.probe(value, "mov")).toThrow(/asset/);
});
it.each(["concat", "hls", "dash", "lavfi", "sdp", "http", "file"])("rejects non-allowlisted demuxer %s", format => {
expect(() => builder.probe(id, format as Demuxer)).toThrow(/demuxer/);
});
it.each([0, 1, 3, 4098, NaN, Infinity])("rejects dimensions %s", width => {
expect(() => builder.transcode(id, "mov", id, { width, height: 720 })).toThrow();
});
it("confines existing supervisor pipes and forces a demuxer on legacy remuxes", () => {
expect(builder.supervisor("ffmpeg", ["-i", "pipe:0", "-c", "copy", "-f", "mpegts", "pipe:1"], "/job").argv).toEqual(["-f", "mpegts", "-protocol_whitelist", "pipe", "-i", "pipe:0", "-c", "copy", "-f", "mpegts", "pipe:1"]);
});
it.each(["file:/etc/passwd", "concat:a|b", "https://example.org", "tcp://127.0.0.1:80"])("rejects a remote manifest endpoint %s", input => {
expect(() => builder.supervisor("ffmpeg", ["-f", "mpegts", "-i", input], "/job")).toThrow();
});
it("rejects IO filters and arbitrary options; permits minted overlay ids", () => {
expect(() => builder.supervisor("ffmpeg", ["-vf", "movie=/etc/passwd"], "/job")).toThrow();
expect(() => builder.supervisor("ffmpeg", ["-report"], "/job")).toThrow();
expect(builder.supervisor("ffmpeg", ["-i", `file:/job/${id}.png`, "-f", "null", "-"], "/job").argv).toContain("png_pipe");
expect(() => builder.supervisor("ffmpeg", ["-i", "file:/job/user-name.png"], "/job")).toThrow();
});
});
it("local defaults off, production defaults on and cannot opt out", () => {
expect(sandboxEnabled({})).toBe(false);
expect(sandboxEnabled({ MEDIA_SANDBOX: "on" })).toBe(true);
expect(sandboxEnabled({ NODE_ENV: "production" })).toBe(true);
expect(() => sandboxEnabled({ NODE_ENV: "production", MEDIA_SANDBOX: "off" })).toThrow(/requires/);
expect(() => sandboxEnabled({ MEDIA_SANDBOX: "maybe" })).toThrow();
expect(sandboxEnvironment()).toEqual({ PATH: "/usr/bin:/bin", LANG: "C", HOME: "/tmp" });
});
it("validates every CPU/memory/pids/time/size limit", () => {
for (const key of Object.keys(DEFAULT_LIMITS)) expect(() => limitsWith({ [key]: 0 })).toThrow();
expect(() => limitsWith({ cpuPeriodUs: 1 })).toThrow();
});
it("validates bounded probe schema without untrusted tags or arbitrary strings", () => {
expect(validateProbe({ format: { format_name: "mov,mp4", duration: "1.3" }, streams: [{ index: 0, codec_type: "video", width: 1920, height: 1080 }] }).streams).toHaveLength(1);
for (const invalid of [null, {}, { format: {}, streams: [], tags: {} }, { format: { format_name: "secret-token" }, streams: [] },
{ format: {}, streams: [{ index: 0, codec_type: "video", width: -1 }] }, { format: {}, streams: Array(65).fill({ index: 0, codec_type: "audio" }) },
{ format: {}, streams: [{ index: 0, codec_type: "audio" }, { index: 0, codec_type: "audio" }] }]) {
expect(() => validateProbe(invalid)).toThrow(/invalid probe/);
}
});

View file

@ -0,0 +1,110 @@
export const DEMUXERS = ["mov", "matroska", "mpegts", "wav", "mp3", "ogg", "flac", "image2", "png_pipe", "jpeg_pipe", "rawvideo", "s16le"] as const;
export type Demuxer = typeof DEMUXERS[number];
export interface MediaCommand { readonly binary: "ffmpeg" | "ffprobe"; readonly argv: readonly string[] }
const issued = new WeakSet<object>();
function command(binary: MediaCommand["binary"], argv: string[]): MediaCommand {
const result = Object.freeze({ binary, argv: Object.freeze(argv) }); issued.add(result); return result;
}
export function assertMediaCommand(value: MediaCommand): void {
if (!issued.has(value)) throw new Error("MediaCommandBuilder command required");
}
export function assertAssetId(id: string): void {
if (!/^(?:[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.test(id)) throw new Error("invalid server asset id");
}
function demuxer(value: string): void {
if (!(DEMUXERS as readonly string[]).includes(value)) throw new Error("demuxer denied");
}
export function assetPath(id: string): string { assertAssetId(id); return `file:/upload/${id}`; }
const FILTERS = new Set(["scale", "pad", "setsar", "crop", "color", "split", "null", "overlay", "xstack", "format", "anullsrc", "aformat", "volume", "amix", "asplit", "noise", "fps", "aresample", "asetpts", "setpts"]);
function validateFilters(text: string): void {
if (text.length > 65536 || /(?:movie|amovie|subtitles|drawtext|zmq|azmq)\s*=|https?:|file:|concat:/i.test(text)) throw new Error("filter IO denied");
let quote = false, escaped = false, start = 0;
const validate = (part: string): void => {
const name = /^(?:\[[a-zA-Z0-9_:]+\])*([a-z][a-z0-9_]*)(?:=|\[|$)/.exec(part)?.[1];
if (!name || !FILTERS.has(name)) throw new Error("filter IO denied");
};
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (escaped) { escaped = false; continue; }
if (c === "\\") { escaped = true; continue; }
if (c === "'") quote = !quote;
if (!quote && (c === "," || c === ";")) { validate(text.slice(start, i)); start = i + 1; }
}
if (quote || escaped) throw new Error("filter IO denied");
validate(text.slice(start));
}
/** No original filename, arbitrary URL, shell string or extra-arguments escape hatch. */
export class MediaCommandBuilder {
probe(assetId: string, format: Demuxer): MediaCommand {
demuxer(format);
return command("ffprobe", ["-hide_banner", "-v", "error", "-protocol_whitelist", "file", "-f", format,
"-show_entries", "format=duration,size,format_name:stream=index,codec_type,codec_name,width,height,sample_rate,channels",
"-of", "json", "-i", assetPath(assetId)]);
}
transcode(assetId: string, format: Demuxer, outputId: string, dimensions: { width: number; height: number }): MediaCommand {
demuxer(format); assertAssetId(outputId);
for (const v of [dimensions.width, dimensions.height]) {
if (!Number.isSafeInteger(v) || v < 2 || v > 4096 || v % 2) throw new Error("invalid dimensions");
}
return command("ffmpeg", ["-hide_banner", "-nostdin", "-loglevel", "error", "-threads", "2", "-filter_threads", "1",
"-protocol_whitelist", "file", "-f", format, "-i", assetPath(assetId), "-vf", `scale=${dimensions.width}:${dimensions.height}`,
"-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-f", "mp4", "-y", `file:/scratch/${outputId}.mp4`]);
}
/** Adapter ONLY for argv from the worker's existing typed graph builders. */
supervisor(binary: MediaCommand["binary"], args: readonly string[], scratch: string): MediaCommand {
if (binary !== "ffmpeg") throw new Error("use the typed probe wrapper");
const flags = new Set(["-hide_banner", "-nostdin", "-re", "-y", "-an", "-vn", "-shortest"]);
const values = new Set(["-loglevel", "-threads", "-threads:v", "-filter_threads", "-filter_complex_threads", "-thread_queue_size", "-probesize", "-analyzeduration",
"-f", "-pix_fmt", "-s", "-framerate", "-ar", "-ac", "-fflags", "-i", "-vf", "-af", "-filter_complex", "-map", "-t", "-c", "-c:v", "-c:a",
"-preset", "-tune", "-profile:v", "-level:v", "-b:v", "-b:a", "-maxrate", "-bufsize", "-g", "-keyint_min", "-sc_threshold",
"-fps_mode", "-flush_packets", "-flags:v", "-flags:a", "-frames:v", "-q:v", "-progress", "-output_ts_offset", "-muxdelay", "-muxpreload",
"-loop", "-mpegts_flags", "-hls_time", "-hls_list_size", "-hls_segment_type", "-hls_fmp4_init_filename", "-hls_flags", "-hls_segment_filename", "-protocol_whitelist"]);
const argv: string[] = []; let format: string | undefined;
const mapPath = (value: string): string => {
const raw = value.startsWith("file:") ? value.slice(5) : value;
if (!raw.startsWith(`${scratch}/`) || raw.split("/").includes("..")) throw new Error("media path denied");
const suffix = raw.slice(scratch.length);
if (!/^\/[A-Za-z0-9_./%\-]+$/.test(suffix)) throw new Error("media path denied");
return `file:/scratch${suffix}`;
};
for (let i = 0; i < args.length; i++) {
const key = args[i]!;
if (flags.has(key)) { argv.push(key); continue; }
if (!key.startsWith("-") || key === "-") {
if (key === "-" && format === "null") argv.push("pipe:1");
else if (format === "tee" && /^\[f=(?:rawvideo|s16le):onfail=ignore\]pipe:\d{1,3}\|\[f=null\]-$/.test(key)) argv.push(key);
else argv.push(/^pipe:\d{1,3}$/.test(key) ? key : mapPath(key));
format = undefined; continue;
}
if (!values.has(key)) throw new Error("media option denied");
let value = args[++i];
if (value === undefined || /[\u0000-\u001f\u007f]/.test(value)) throw new Error("media option denied");
if (key === "-protocol_whitelist") continue; // Replaced per input below.
if (key === "-f") format = value;
if (["-vf", "-af", "-filter_complex"].includes(key)) validateFilters(value);
if (key === "-i") {
if (/^pipe:\d{1,3}$/.test(value)) {
if (!format) { format = "mpegts"; argv.push("-f", format); } demuxer(format);
argv.push("-protocol_whitelist", "pipe");
} else if (format === "lavfi") {
if (!/^(?:testsrc2|smptehdbars|mandelbrot|rgbtestsrc|sine|color|anullsrc)=/.test(value) || /[\/;]|(?:movie|http|file|concat):/i.test(value)) throw new Error("synthetic input denied");
argv.push("-protocol_whitelist", "file");
} else {
const path = mapPath(value);
if (!/\/[0-9a-f]{64}\.png$/.test(path)) throw new Error("asset input denied");
argv.push("-protocol_whitelist", "file", "-f", "png_pipe"); value = path;
}
format = undefined;
} else if (key === "-hls_segment_filename") { argv.push(key, mapPath(value)); continue; }
else if (key === "-progress" && !/^pipe:\d{1,3}$/.test(value)) throw new Error("progress endpoint denied");
else if (key === "-hls_fmp4_init_filename" && value !== "init.mp4") throw new Error("init filename denied");
else if (!["-i", "-vf", "-af", "-filter_complex", "-framerate"].includes(key) && /(?:https?|rtmp|srt|tcp|udp|file|concat):|\//i.test(value)) throw new Error("media protocol denied");
argv.push(key, value);
}
return command(binary, argv);
}
}

View file

@ -0,0 +1,4 @@
export * from "./command";
export * from "./profile";
export * from "./runtime";
export * from "./probe";

View file

@ -0,0 +1,58 @@
import { MediaCommandBuilder, type Demuxer } from "./command";
import { spawnSandbox, type SandboxChild, type SandboxOptions } from "./runtime";
export interface ProbeResult {
format: { format_name?: string; duration?: string; size?: string };
streams: Array<{ index: number; codec_type: "video" | "audio" | "subtitle" | "data" | "attachment"; codec_name?: string; width?: number; height?: number; sample_rate?: string; channels?: number }>;
}
const fail = (): never => { throw new Error("invalid probe output"); };
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
return value as Record<string, unknown>;
}
function keys(value: Record<string, unknown>, allowed: string[]): void {
if (Object.keys(value).some(key => !allowed.includes(key))) fail();
}
export function validateProbe(value: unknown): ProbeResult {
const root = record(value); keys(root, ["format", "streams"]);
const format = record(root.format); keys(format, ["format_name", "duration", "size"]);
for (const [k, v] of Object.entries(format)) {
if (typeof v !== "string" || v.length > 128 || !(k === "format_name" ? /^[a-z0-9_,]+$/ : /^\d+(?:\.\d+)?$/).test(v)) fail();
if (k !== "format_name" && !Number.isFinite(Number(v))) fail();
}
if (!Array.isArray(root.streams) || root.streams.length > 64) fail();
const indices = new Set<number>();
for (const raw of root.streams as unknown[]) {
const stream = record(raw); keys(stream, ["index", "codec_type", "codec_name", "width", "height", "sample_rate", "channels"]);
if (!Number.isSafeInteger(stream.index) || Number(stream.index) < 0 || indices.has(Number(stream.index))) fail();
indices.add(Number(stream.index));
if (!["video", "audio", "subtitle", "data", "attachment"].includes(String(stream.codec_type))) fail();
for (const k of ["width", "height", "channels"]) {
if (stream[k] !== undefined && (!Number.isSafeInteger(stream[k]) || Number(stream[k]) <= 0 || Number(stream[k]) > (k === "channels" ? 64 : 16384))) fail();
}
if (stream.codec_name !== undefined && (typeof stream.codec_name !== "string" || !/^[a-z0-9_]{1,128}$/.test(stream.codec_name))) fail();
if (stream.sample_rate !== undefined && (typeof stream.sample_rate !== "string" || !/^\d{1,7}$/.test(stream.sample_rate) || Number(stream.sample_rate) > 768000)) fail();
}
return value as ProbeResult;
}
/** Exported collector allows testing hostile/oversized output using a real child. */
export async function collectProbe(job: SandboxChild, maxBytes = 256 * 1024, onLog: (line: string) => void = () => undefined): Promise<ProbeResult> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1024 * 1024) { job.kill(); throw new Error("invalid probe output cap"); }
let size = 0; let oversized = false; const chunks: Buffer[] = [];
job.child.stdout?.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > maxBytes) { oversized = true; job.kill(); return; }
chunks.push(chunk);
});
job.child.stderr?.on("data", () => onLog("media probe stderr suppressed"));
let spawnFailed = false; job.child.once("error", () => { spawnFailed = true; });
const result = await job.exited;
if (oversized) throw new Error("probe output limit exceeded");
if (spawnFailed || result.code !== 0) throw new Error("media probe failed");
let parsed: unknown;
try { parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { return fail(); }
return validateProbe(parsed);
}
export function probe(assetId: string, format: Demuxer, options: SandboxOptions, onLog?: (line: string) => void): Promise<ProbeResult> {
if (!options.asset || options.asset.id !== assetId) throw new Error("probe asset mapping required");
return collectProbe(spawnSandbox(new MediaCommandBuilder().probe(assetId, format), options, { stdio: ["ignore", "pipe", "pipe"] }), undefined, onLog);
}

View file

@ -0,0 +1,25 @@
export interface SandboxLimits {
cpuQuotaUs: number;
cpuPeriodUs: number;
memoryBytes: number;
pids: number;
wallTimeMs: number;
fileSizeBytes: number;
}
export const DEFAULT_LIMITS: Readonly<SandboxLimits> = Object.freeze({
cpuQuotaUs: 200_000, cpuPeriodUs: 100_000, memoryBytes: 512 * 1024 * 1024,
pids: 64, wallTimeMs: 120_000, fileSizeBytes: 1024 * 1024 * 1024,
});
export function limitsWith(overrides: Partial<SandboxLimits> = {}): SandboxLimits {
const result = { ...DEFAULT_LIMITS, ...overrides };
for (const v of Object.values(result)) if (!Number.isSafeInteger(v) || v <= 0) throw new Error("invalid sandbox limit");
if (result.cpuPeriodUs < 1000 || result.cpuPeriodUs > 1_000_000 || result.pids < 4) throw new Error("invalid sandbox limit");
return result;
}
export function sandboxEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.MEDIA_SANDBOX !== undefined && env.MEDIA_SANDBOX !== "on" && env.MEDIA_SANDBOX !== "off") throw new Error("MEDIA_SANDBOX must be on or off");
if (env.NODE_ENV === "production" && env.MEDIA_SANDBOX === "off") throw new Error("production requires media sandbox");
return env.MEDIA_SANDBOX === "on" || env.NODE_ENV === "production";
}
/** PATH is fixed; no inherited keys, tokens, proxy settings, loader options or config. */
export function sandboxEnvironment(): NodeJS.ProcessEnv { return { PATH: "/usr/bin:/bin", LANG: "C", HOME: "/tmp" }; }

View file

@ -0,0 +1,103 @@
import { spawn, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { MediaCommandBuilder } from "./command";
import { collectProbe } from "./probe";
import { sandboxEnvironment } from "./profile";
import { helperPath, sandboxCapability, spawnSandbox, type SandboxChild, type SandboxOptions } from "./runtime";
const work = mkdtempSync(join(tmpdir(), "media-security-tests-"));
const rootfs = join(work, "rootfs");
for (const dir of ["upload", "scratch", "tmp", "proc", "dev", "usr/bin"]) mkdirSync(join(rootfs, dir), { recursive: true });
writeFileSync(join(rootfs, "dev/null"), "");
const built = spawnSync("cc", ["-static", "-O0", "-Wall", "-Wextra", "-Werror", resolve(__dirname, "../native/test-child.c"), "-o", join(rootfs, "usr/bin/ffprobe")]);
if (built.status === 0) spawnSync("cp", [join(rootfs, "usr/bin/ffprobe"), join(rootfs, "usr/bin/ffmpeg")]);
const options = { rootfs, cgroupRoot: process.env.MEDIA_SANDBOX_CGROUP_ROOT ?? "/sys/fs/cgroup" };
const capability = built.status === 0 ? sandboxCapability(options) : { available: false, reason: "static C test fixture compiler unavailable" };
if (!capability.available) console.warn(`SKIP sandbox integration and sandbox canaries: ${capability.reason}`);
afterAll(() => rmSync(work, { recursive: true, force: true }));
const builder = new MediaCommandBuilder(); let sequence = 0;
function job(action: string, limits?: SandboxOptions["limits"]): SandboxChild {
const id = (++sequence).toString(16).padStart(64, "0"); const file = join(work, id);
writeFileSync(file, action);
return spawnSandbox(builder.probe(id, "wav"), { ...options, limits, asset: { id, resolve: () => file } }, { stdio: ["ignore", "pipe", "pipe"] });
}
async function output(child: SandboxChild): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
let stdout = "", stderr = "";
child.child.stdout?.on("data", chunk => { stdout += String(chunk); });
child.child.stderr?.on("data", chunk => { stderr += String(chunk); });
return { ...await child.exited, stdout, stderr };
}
function ready(child: SandboxChild): Promise<void> {
return new Promise((done, reject) => { child.child.stdout!.once("data", () => done()); child.child.once("exit", () => reject(new Error("sandbox failed before ready"))); });
}
describe.skipIf(!capability.available)("rootless sandbox confinement", () => {
it("cannot read host/vault/DB/other-job files; root is read-only and scratch works; UIDs differ", async () => {
const secret = join(work, "vault-db-sibling-secret"); writeFileSync(secret, "private");
const first = await output(job(`isolation\n${secret}\n`));
const second = await output(job(`isolation\n${secret}\n`));
expect(first.code, first.stderr).toBe(0); expect(second.code, second.stderr).toBe(0);
expect(first.stdout).toContain("isolated uid="); expect(first.stdout).not.toEqual(second.stdout);
});
it("blocks socket creation, DNS, loopback, internal CIDRs and metadata", async () => {
const result = await output(job("network\n")); expect(result.code, result.stderr).toBe(0); expect(result.stdout).toContain("network denied");
});
it("kills memory/time offenders without affecting a sibling", async () => {
const sibling = job("sleep\n", { wallTimeMs: 30_000 });
try {
await ready(sibling);
const memory = await output(job("memory\n", { memoryBytes: 16 * 1024 * 1024, wallTimeMs: 5000 }));
expect(memory.stdout).toContain("ready"); expect(memory.signal).toBe("SIGKILL");
expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull();
const timed = await output(job("sleep\n", { wallTimeMs: 1000 })); expect(timed.stdout).toContain("ready"); expect(timed.signal).toBe("SIGKILL");
expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull();
} finally { sibling.kill(); await sibling.exited; }
}, 15_000);
it("enforces pids and file-size caps without affecting siblings", async () => {
const sibling = job("sleep\n", { wallTimeMs: 30_000 });
try {
await ready(sibling);
const pids = await output(job("pids\n", { pids: 12 })); expect(pids.code, pids.stderr).toBe(0); expect(pids.stdout).toContain("pids denied");
const size = await output(job("size\n", { fileSizeBytes: 8192 })); expect(size.code).not.toBe(0);
expect(sibling.child.exitCode).toBeNull(); expect(sibling.child.signalCode).toBeNull();
} finally { sibling.kill(); await sibling.exited; }
}, 15_000);
it("caps and validates hostile probe output", async () => {
expect((await collectProbe(job("probe-good\n"))).streams).toHaveLength(1);
await expect(collectProbe(job("probe-big\n"), 4096)).rejects.toThrow(/limit exceeded/);
await expect(collectProbe(job("probe-invalid\n"))).rejects.toThrow(/invalid probe/);
});
it("canary key/token/passphrase stay absent from every process cmdline/environ and captured output/log", async () => {
const canaries = ["stream-key-canary-95c70", "oauth-token-canary-d7921", "srt-passphrase-canary-f1f12"];
const names = ["CANARY_STREAM_KEY", "CANARY_OAUTH_TOKEN", "CANARY_SRT_PASSPHRASE"];
const before = names.map(name => process.env[name]); names.forEach((name, i) => { process.env[name] = canaries[i]; });
try {
const child = job("metadata\n");
const captured = output(child);
// The host-visible wrapper stays alive while the child inspects its final /proc metadata itself.
await new Promise(done => setTimeout(done, 100));
const metadata = [readFileSync(`/proc/${child.child.pid}/cmdline`), readFileSync(`/proc/${child.child.pid}/environ`)];
const result = await captured;
expect(result.code).toBe(7);
expect(result.stdout).toContain("PATH=/usr/bin:/bin");
expect(result.stderr).toContain("forced authentication/connection failure");
for (const canary of canaries) for (const text of [...metadata.map(v => v.toString()), result.stdout, result.stderr]) expect(text).not.toContain(canary);
} finally { names.forEach((name, i) => { if (before[i] === undefined) delete process.env[name]; else process.env[name] = before[i]; }); }
});
});
it("probe collector caps output and never logs hostile stderr, even without sandbox capability", async () => {
const canary = "stream-key-canary-oversized"; const logs: string[] = [];
const child = spawn(process.execPath, ["-e", 'process.stdin.once("data",b=>{process.stderr.write(b);process.stdout.write("x".repeat(50000))})'], { env: sandboxEnvironment(), stdio: ["pipe", "pipe", "pipe"] });
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(done => child.once("close", (code, signal) => done({ code, signal })));
const collected = collectProbe({ child, exited, kill: () => { child.kill("SIGKILL"); } }, 4096, line => logs.push(line));
child.stdin!.end(canary);
await expect(collected).rejects.toThrow(/limit exceeded/);
expect(logs.length).toBeGreaterThan(0); expect(logs.join("\n")).not.toContain(canary);
});
it("installs the real seccomp profile and no-new-privileges without needing userns", () => {
const result = spawnSync(helperPath(), ["--seccomp-check"], { env: sandboxEnvironment(), timeout: 5000 });
expect(result.status, String(result.stderr)).toBe(0);
});

View file

@ -0,0 +1,172 @@
import { spawn, spawnSync, type ChildProcess, type SpawnOptions } from "node:child_process";
import { constants, closeSync, fstatSync, openSync, readSync, writeSync, chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, rmdirSync, statfsSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { assertAssetId, assertMediaCommand, type MediaCommand } from "./command";
import { limitsWith, sandboxEnvironment, type SandboxLimits } from "./profile";
export interface SandboxOptions {
/** Dedicated, operator-built minimal FFmpeg rootfs; never the host /. */
rootfs: string;
/** Empty cgroup v2 subtree delegated to this worker with cpu/memory/pids enabled. */
cgroupRoot: string;
helperPath?: string;
scratchDir?: string;
limits?: Partial<SandboxLimits>;
/** Server asset resolver; called only after validating the minted id. */
asset?: { id: string; resolve(id: string): string };
}
export class SandboxUnavailable extends Error { constructor() { super("media sandbox unavailable: requires mapped unprivileged UID/userns, mount/net/pid namespaces, trusted rootfs and delegated cgroup v2"); this.name = "SandboxUnavailable"; } }
export interface SandboxChild {
child: ChildProcess;
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
kill(signal?: NodeJS.Signals): void;
}
const activeUids = new Set<number>(); let nextUid = 1000;
const uidLeases = join(tmpdir(), `media-sandbox-uids-${process.getuid?.() ?? "unsupported"}`);
function uid(): number {
try { mkdirSync(uidLeases, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new SandboxUnavailable(); }
const stat = lstatSync(uidLeases);
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw new SandboxUnavailable();
for (let n = 0; n <= 59000; n++) {
const v = nextUid; nextUid = nextUid >= 60000 ? 1000 : nextUid + 1;
if (activeUids.has(v)) continue;
try { mkdirSync(join(uidLeases, String(v)), { mode: 0o700 }); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; throw new SandboxUnavailable(); }
activeUids.add(v); return v;
}
throw new SandboxUnavailable();
}
function releaseUid(value: number): void {
try { rmdirSync(join(uidLeases, String(value))); } catch { /* stale leases remain reserved rather than risk UID reuse */ }
activeUids.delete(value);
}
export function helperPath(): string { return resolve(__dirname, "../dist/media-confine"); }
function checkedRootfs(path: string): string {
const root = realpathSync(path);
if (root === "/" || root === "/usr" || root === "/etc") throw new SandboxUnavailable();
for (const dir of ["upload", "scratch", "tmp", "proc", "dev"]) {
if (!lstatSync(join(root, dir)).isDirectory() || lstatSync(join(root, dir)).isSymbolicLink()) throw new SandboxUnavailable();
}
if (!lstatSync(join(root, "dev/null")).isFile()) throw new SandboxUnavailable();
// A recursive bind with writable nested mounts would undermine the read-only root.
if (readFileSync("/proc/self/mountinfo", "utf8").split("\n").some(line => {
const mount = line.split(" ")[4]?.replace(/\\040/g, " "); return mount?.startsWith(`${root}/`);
})) throw new SandboxUnavailable();
return root;
}
function createCgroup(root: string, limits: SandboxLimits): string {
const base = realpathSync(root);
if (statfsSync(base).type !== 0x63677270 || !existsSync(join(base, "cgroup.controllers"))) throw new SandboxUnavailable();
const path = join(base, `media-${randomUUID()}`);
mkdirSync(path);
try {
writeFileSync(join(path, "cpu.max"), `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`);
writeFileSync(join(path, "memory.max"), String(limits.memoryBytes));
writeFileSync(join(path, "memory.swap.max"), "0");
writeFileSync(join(path, "memory.oom.group"), "1");
writeFileSync(join(path, "pids.max"), String(limits.pids));
if (!existsSync(join(path, "cgroup.kill"))) throw new SandboxUnavailable();
return path;
} catch { rmdirSync(path); throw new SandboxUnavailable(); }
}
function confinementArgs(helper: string, group: string, root: string, rootfs: string, uploads: string, scratch: string, jobUid: number, limits: SandboxLimits, executable: string, argv: readonly string[]): string[] {
return ["--join", join(group, "cgroup.procs"), "/usr/bin/unshare", "--user", "--map-auto", "--map-user=0", "--setgroups=allow", "--mount", "--net", "--pid", "--fork", "--kill-child",
helper, "--confine", root, rootfs, uploads, scratch, String(jobUid), String(limits.fileSizeBytes), String(Math.ceil(limits.wallTimeMs / 1000)), executable, ...argv];
}
export function sandboxCapability(options: SandboxOptions): { available: boolean; reason?: string } {
let group: string | undefined; let work: string | undefined; let detail = ""; let jobUid: number | undefined;
try {
jobUid = uid();
const rootfs = checkedRootfs(options.rootfs); const limits = limitsWith(options.limits);
const helper = options.helperPath ?? helperPath();
if (!existsSync(helper)) throw new SandboxUnavailable();
work = mkdtempSync(join(tmpdir(), "media-sandbox-check-"));
const root = join(work, "root"), uploads = join(work, "uploads"), scratch = join(work, "scratch");
for (const dir of [root, uploads, scratch]) mkdirSync(dir);
chmodSync(scratch, 0o777);
group = createCgroup(options.cgroupRoot, limits);
const result = spawnSync(helper, confinementArgs(helper, group, root, rootfs, uploads, scratch, jobUid, limits, "--check", []), { env: sandboxEnvironment(), timeout: 5000 });
if (result.status !== 0) {
if (String(result.stderr).includes("newuidmap")) detail = " (UID mapping helper failed)";
throw new SandboxUnavailable();
}
return { available: true };
} catch { return { available: false, reason: new SandboxUnavailable().message + detail }; }
finally {
if (group) { try { writeFileSync(join(group, "cgroup.kill"), "1"); rmdirSync(group); } catch { /* timed-out setup */ } }
if (work) rmSync(work, { recursive: true, force: true }); if (jobUid !== undefined) releaseUid(jobUid);
}
}
/** Synchronous construction keeps existing spawnManaged lifecycle/stdio semantics. */
export function spawnSandbox(command: MediaCommand, options: SandboxOptions, spawnOptions: SpawnOptions = {}, spawner: typeof spawn = spawn): SandboxChild {
assertMediaCommand(command);
const limits = limitsWith(options.limits); const jobUid = uid();
let work: string | undefined; let group: string | undefined;
try {
const rootfs = checkedRootfs(options.rootfs);
work = mkdtempSync(join(tmpdir(), "media-sandbox-")); chmodSync(work, 0o700);
const root = join(work, "root"); const uploads = join(work, "uploads"); const scratch = options.scratchDir ? realpathSync(options.scratchDir) : join(work, "scratch");
mkdirSync(root); mkdirSync(uploads, { mode: 0o755 });
if (!options.scratchDir) { mkdirSync(scratch); chmodSync(scratch, 0o777); }
else {
const parent = lstatSync(dirname(scratch));
if (scratch === "/" || !lstatSync(scratch).isDirectory() || parent.uid !== process.getuid?.() || (parent.mode & 0o077) !== 0) throw new SandboxUnavailable();
}
if (options.asset) {
assertAssetId(options.asset.id);
const source = options.asset.resolve(options.asset.id);
const input = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
let dest: number | undefined;
try {
const stat = fstatSync(input);
if (!stat.isFile() || stat.size > limits.fileSizeBytes) throw new Error("upload denied");
const path = join(uploads, options.asset.id);
dest = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
const buffer = Buffer.alloc(64 * 1024); let total = 0; let count: number;
while ((count = readSync(input, buffer, 0, buffer.length, null)) > 0) {
total += count; if (total > limits.fileSizeBytes) throw new Error("upload denied");
let written = 0;
while (written < count) written += writeSync(dest, buffer, written, count - written);
}
chmodSync(path, 0o444);
} finally { closeSync(input); if (dest !== undefined) closeSync(dest); }
}
group = createCgroup(options.cgroupRoot, limits);
const helper = options.helperPath ?? helperPath();
const args = confinementArgs(helper, group, root, rootfs, uploads, scratch, jobUid, limits, `/usr/bin/${command.binary}`, command.argv);
const child = spawner(helper, args, { ...spawnOptions, cwd: undefined, shell: false, detached: true, env: sandboxEnvironment() });
const groupPath = group; const workPath = work;
const kill = (signal: NodeJS.Signals = "SIGKILL"): void => {
if (child.exitCode !== null || child.signalCode !== null) return;
if (signal === "SIGKILL") { try { writeFileSync(join(groupPath, "cgroup.kill"), "1"); } catch { /* group may already be empty */ } }
if (child.pid) { try { process.kill(-child.pid, signal); } catch { /* already exited */ } }
};
const timer = setTimeout(() => kill(), limits.wallTimeMs); timer.unref();
let cleaned = false;
const cleanup = (): void => {
if (cleaned) return; cleaned = true; clearTimeout(timer);
try { writeFileSync(join(groupPath, "cgroup.kill"), "1"); } catch { /* startup failure */ }
try { rmdirSync(groupPath); } catch { /* transient kernel teardown; no siblings touched */ }
rmSync(workPath, { force: true, recursive: true }); releaseUid(jobUid);
};
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolveExit => {
child.once("close", (code, signal) => { cleanup(); resolveExit({ code, signal }); });
});
child.once("error", () => { if (!child.pid) cleanup(); });
// Existing ManagedProcess.kill must also kill the wrapper's whole job.
const nativeKill = child.kill.bind(child);
child.kill = (signal?: NodeJS.Signals | number) => {
if (typeof signal === "number") return nativeKill(signal);
kill(signal ?? "SIGTERM"); return true;
};
return { child, exited, kill };
} catch (error) {
if (group) { try { rmdirSync(group); } catch { /* no child launched */ } }
if (work) rmSync(work, { force: true, recursive: true }); releaseUid(jobUid);
if (error instanceof Error && error.message === "upload denied") throw error;
throw new SandboxUnavailable();
}
}