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
56
packages/media-sandbox/src/command.test.ts
Normal file
56
packages/media-sandbox/src/command.test.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
110
packages/media-sandbox/src/command.ts
Normal file
110
packages/media-sandbox/src/command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
packages/media-sandbox/src/index.ts
Normal file
4
packages/media-sandbox/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./command";
|
||||
export * from "./profile";
|
||||
export * from "./runtime";
|
||||
export * from "./probe";
|
||||
58
packages/media-sandbox/src/probe.ts
Normal file
58
packages/media-sandbox/src/probe.ts
Normal 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);
|
||||
}
|
||||
25
packages/media-sandbox/src/profile.ts
Normal file
25
packages/media-sandbox/src/profile.ts
Normal 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" }; }
|
||||
103
packages/media-sandbox/src/runtime.test.ts
Normal file
103
packages/media-sandbox/src/runtime.test.ts
Normal 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);
|
||||
});
|
||||
172
packages/media-sandbox/src/runtime.ts
Normal file
172
packages/media-sandbox/src/runtime.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue