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
38
packages/media-sandbox/README.md
Normal file
38
packages/media-sandbox/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# @tactical-media-broadcast-hub/media-sandbox
|
||||
|
||||
WP4/WP9 Linux runtime for untrusted uploaded media and the worker's existing typed FFmpeg graph builders. Commands contain argv arrays, never shell text. Uploads use a server-minted UUID or SHA-256 id and appear as `file:/upload/<id>`; the resolver's host path and original filename do not enter media argv. The runtime copies only that regular file, with `O_NOFOLLOW` and a size cap, into private staging. Client filenames are metadata only. Do not expose the server asset resolver or the supervisor argv adapter as a client API.
|
||||
|
||||
Build with `pnpm --dir packages/media-sandbox build`, typecheck with `pnpm --dir packages/media-sandbox typecheck`, and test with `pnpm --dir packages/media-sandbox test`. A C compiler and Linux headers are required. The native launcher is generated at `dist/media-confine`. Build this package before starting the media-worker; its supervisor resolves the built CommonJS entry without a worker manifest or lockfile change. The worker still typechecks independently before that build.
|
||||
|
||||
## Runtime profile
|
||||
|
||||
The launcher joins the job's cgroup **before** executing `unshare`, so mapping helpers and all media descendants inherit its limits. `unshare --user --map-auto --map-user=0 --setgroups=allow --mount --net --pid --fork --kill-child` requires subordinate UID/GID ranges and working `newuidmap`/`newgidmap`. Mapping the caller UID to namespace root separately preserves `setgroups=allow` for the subordinate GID mapping; `--map-root-user` would force setgroups denial and prevent dropping inherited groups. Namespace root sets up the filesystem, drops the capability bounding set, supplementary groups, and all effective/permitted/inheritable capabilities, then switches to a distinct nonzero mapped UID/GID for each concurrently active child. UID leases use atomic private directories under `/tmp/media-sandbox-uids-<worker-uid>` so concurrent worker processes sharing a subordinate range also use distinct host UIDs. Leases are released after job teardown; after a worker crash, stale lease directories deliberately remain reserved and can be removed once its children are confirmed gone. A single-UID user namespace is deliberately insufficient. No privileged/root fallback exists.
|
||||
|
||||
The mount namespace binds a dedicated minimal rootfs read-only (`nosuid,nodev`), the single upload directory read-only (`noexec`), and the channel/job scratch directory writable (`noexec`). It adds a private 16 MiB tmpfs, read-only procfs exposing only the job PID namespace, and `/dev/null`. It never binds host `/`, `/etc`, a full upload bucket, home, vault/DB paths, Docker sockets or other jobs. Nested mounts in the supplied rootfs are rejected. Caller-supplied scratch must have a worker-owned 0700 parent; the scratch directory is writable to the mapped UID. The supervisor generates scratch names from a workspace/channel hash and prepares existing generated output directories through directory descriptors, without following symlinks.
|
||||
|
||||
`native/confine.c` contains the architecture-checked seccomp BPF profile (x86-64 and aarch64). It rejects the x32 ABI, new sockets/connect/bind/sendmsg, mount/chroot/namespace creation or joining, ptrace/process-memory access, keyrings, module loading, BPF/perf, file-handle access, userfaultfd and io_uring. It rejects namespace flags in `clone` and returns ENOSYS for `clone3` to allow ordinary thread fallback. Ordinary FFmpeg decoding syscalls remain allowed. `no-new-privileges` and zero capabilities prevent privilege regain. The empty network namespace has no DNS resolver or route; inherited relay pipes remain usable.
|
||||
|
||||
The dedicated delegated cgroup v2 subtree must have `cpu`, `memory`, and `pids` controllers enabled by the operator. Each job gets `cpu.max`, `memory.max`, `memory.swap.max=0`, `memory.oom.group=1`, and `pids.max`. File size uses `RLIMIT_FSIZE`, CPU execution has an `RLIMIT_CPU` ceiling, and a supervisor wall timer uses `cgroup.kill` plus the job process group. Core dumps are disabled. Failures and termination clean only the job's cgroup and staging. CPU/time/file size are per process where implemented by rlimits; memory/pids/CPU quota and wall termination cover descendants. A scratch filesystem quota is still appropriate if an operator needs a total disk-byte cap in addition to the per-file cap.
|
||||
|
||||
Probe inputs have `-protocol_whitelist file` and a forced demuxer from `DEMUXERS`. HLS, DASH, concat, SDP and other remote manifests are excluded. Probe JSON is restricted to selected stream/format fields, capped at 256 KiB, and validated for shape, types, bounds, allowed string syntax, unique stream indexes and at most 64 streams. Parse failures never echo input. Tags, filenames and arbitrary metadata do not reach the result. Probe stderr is drained and logged only as a fixed suppression message. Commands are frozen and runtime-branded; fabricated argv objects cannot be spawned.
|
||||
|
||||
## Worker integration and deployment
|
||||
|
||||
`apps/media-worker/src/supervisor/sandbox.ts` uses an AsyncLocalStorage context around a channel's start/stop/reconfigure. It installs one Node spawn adapter; only FFmpeg/ffprobe calls in that context are mediated. Other spawns retain their original behavior. Existing relay/compositor/playlist/preview FFmpeg children retain their stdio descriptors and ManagedProcess API. The package's supervisor adapter validates existing typed argv, forces input demuxers and per-input protocol whitelists, translates minted overlay/scratch paths, rejects IO filters and remote URLs, and never accepts arbitrary FFmpeg switches. Current worker jobs have no ffprobe call; upload probes must use the package's typed `probe(assetId, demuxer, options)` wrapper. A freeform ffprobe spawn in the supervisor is rejected.
|
||||
|
||||
`MEDIA_SANDBOX=off` is the local default. `MEDIA_SANDBOX=on`, or `NODE_ENV=production` with no flag, enables confinement. Production explicitly setting `off` is rejected at supervisor construction. Enabled operation requires:
|
||||
|
||||
- `MEDIA_SANDBOX_ROOTFS`: an operator-owned, immutable, dedicated FFmpeg runtime rootfs. Include `/usr/bin/ffmpeg`, `/usr/bin/ffprobe` and only their required runtime libraries, plus real empty directories `/upload`, `/scratch`, `/tmp`, `/proc`, `/dev` and a regular empty `/dev/null` mount target. No symlink mount targets or nested mounts. Pin the FFmpeg/ffprobe version and image digest in the deployment image and keep that version under CVE watch; this package does not silently use a host installation in production.
|
||||
- `MEDIA_SANDBOX_CGROUP_ROOT`: the empty worker-delegated cgroup subtree (do not delegate the host cgroup root).
|
||||
- Working subordinate UID/GID mappings, mount/net/PID namespaces, seccomp, and cgroup v2 including `cgroup.kill`.
|
||||
- Optional `MEDIA_SCRATCH_DIR` (same setting as existing production jobs) and `MEDIA_SANDBOX_WALL_TIME_MS` (channel default 24 hours; package/upload default 120 seconds).
|
||||
|
||||
All children receive fixed PATH/LANG/HOME only. The supervisor replaces the public stderr stream with fixed suppression messages before `spawnManaged` attaches a logger; a passthrough caller redactor cannot leak attacker-controlled stderr or partial secret strings. Native failures also use fixed messages. The media supervisor keeps existing restart/version logic.
|
||||
|
||||
This boundary covers FFmpeg/ffprobe children. Existing native frame-gate/source-bus and egress relay processes are outside this integration; their isolation requires their respective runtime wiring. Relay transport remains fd-only, with external routing confined by the existing endpoint-validation/egress layer.
|
||||
|
||||
## Security tests
|
||||
|
||||
Vitest covers minted-path construction, protocol/demuxer rejection, frozen command branding, limit validation, probe schema/output overflow, and the real native seccomp/no-new-privileges profile without namespaces. Capability-dependent tests execute a statically compiled adversarial media child inside the full runtime: host/vault/DB/sibling-file denial, read-only root, different UIDs, network denial, memory/time/pids/file-size bounds with a surviving sibling, capped/invalid probe output, and planted stream-key/OAuth-token/SRT-passphrase canaries against wrapper and final child `/proc` metadata, stderr and captured output. The full capability check executes the actual native confinement, including UID dropping and seccomp; unsupported hosts skip with a fixed explanatory message.
|
||||
|
||||
Worker tests drive a freshly signed command through the real supervisor into `spawnManaged`, inspect `/proc/<pid>/cmdline` and `environ`, force a real FFmpeg error, and scan captured stderr and every supervisor log line. The local canary runs on ordinary hosts; the enabled canary requires the full rootless runtime and skips explicitly when unavailable. Existing production argv compatibility and fail-closed configuration behavior are also checked.
|
||||
124
packages/media-sandbox/native/confine.c
Normal file
124
packages/media-sandbox/native/confine.c
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
#define _GNU_SOURCE
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <grp.h>
|
||||
#include <linux/audit.h>
|
||||
#include <linux/capability.h>
|
||||
#include <linux/filter.h>
|
||||
#include <linux/seccomp.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/mount.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/resource.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* All errors are fixed text: never echo argv, paths, environment or media bytes. */
|
||||
static void fail(void) { fputs("media sandbox confinement failed\n", stderr); exit(125); }
|
||||
static void check(int result) { if (result < 0) fail(); }
|
||||
static unsigned long number(const char *s) {
|
||||
char *end; errno = 0; unsigned long n = strtoul(s, &end, 10);
|
||||
if (errno || !*s || *end) fail();
|
||||
return n;
|
||||
}
|
||||
static void limit(int resource, unsigned long value) {
|
||||
struct rlimit r = { value, value }; check(setrlimit(resource, &r));
|
||||
}
|
||||
static void target(char *out, size_t size, const char *root, const char *name) {
|
||||
if (snprintf(out, size, "%s%s", root, name) >= (int)size) fail();
|
||||
}
|
||||
static void filter(void) {
|
||||
#if defined(__x86_64__)
|
||||
#define ARCH AUDIT_ARCH_X86_64
|
||||
#elif defined(__aarch64__)
|
||||
#define ARCH AUDIT_ARCH_AARCH64
|
||||
#else
|
||||
#error Unsupported seccomp architecture
|
||||
#endif
|
||||
#define DENY(n) BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_##n, 0, 1), BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|EPERM)
|
||||
struct sock_filter rules[] = {
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, arch)),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, ARCH, 1, 0),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_KILL_PROCESS),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, nr)),
|
||||
/* Reject the x32 ABI as well as unknown syscall-number namespaces. */
|
||||
BPF_JUMP(BPF_JMP|BPF_JGE|BPF_K, 0x40000000, 0, 1),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_KILL_PROCESS),
|
||||
DENY(socket), DENY(socketpair), DENY(connect), DENY(bind), DENY(listen),
|
||||
DENY(accept), DENY(accept4), DENY(sendto), DENY(sendmsg), DENY(sendmmsg),
|
||||
DENY(mount), DENY(umount2), DENY(pivot_root), DENY(chroot), DENY(unshare), DENY(setns),
|
||||
DENY(ptrace), DENY(process_vm_readv), DENY(process_vm_writev),
|
||||
DENY(bpf), DENY(perf_event_open), DENY(keyctl), DENY(add_key), DENY(request_key),
|
||||
DENY(reboot), DENY(kexec_load), DENY(init_module), DENY(finit_module), DENY(delete_module),
|
||||
DENY(open_by_handle_at), DENY(name_to_handle_at), DENY(userfaultfd),
|
||||
DENY(io_uring_setup), DENY(io_uring_enter), DENY(io_uring_register),
|
||||
/* FFmpeg needs threads, never new namespaces. clone3 falls back to clone. */
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone3, 0, 1),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|ENOSYS),
|
||||
BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone, 0, 3),
|
||||
BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, args[0])),
|
||||
BPF_JUMP(BPF_JMP|BPF_JSET|BPF_K, 0x7e020000, 0, 1),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|EPERM),
|
||||
BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ALLOW)
|
||||
};
|
||||
struct sock_fprog p = { sizeof(rules)/sizeof(rules[0]), rules };
|
||||
check(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0));
|
||||
check(prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &p));
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
if (argc == 2 && !strcmp(argv[1], "--seccomp-check")) {
|
||||
filter();
|
||||
if (socket(AF_INET, SOCK_STREAM, 0) != -1 || errno != EPERM) fail();
|
||||
if (syscall(SYS_unshare, 0) != -1 || errno != EPERM) fail();
|
||||
if (syscall(SYS_clone3, NULL, 0) != -1 || errno != ENOSYS) fail();
|
||||
if (prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) != 1 || getpid() <= 0) fail();
|
||||
return 0;
|
||||
}
|
||||
/* Join before unshare forks: every descendant inherits the bounded cgroup. */
|
||||
if (argc > 4 && !strcmp(argv[1], "--join")) {
|
||||
int fd = open(argv[2], O_WRONLY|O_CLOEXEC|O_NOFOLLOW); check(fd);
|
||||
char pid[32]; int n = snprintf(pid, sizeof(pid), "%ld", (long)getpid());
|
||||
if (write(fd, pid, n) != n) fail();
|
||||
check(close(fd)); execv(argv[3], &argv[3]); fail();
|
||||
}
|
||||
/* root staging, trusted rootfs, upload staging, scratch, uid, size, CPU seconds, executable, argv */
|
||||
if (argc < 10 || strcmp(argv[1], "--confine")) fail();
|
||||
const char *root = argv[2]; unsigned long uid = number(argv[6]);
|
||||
if (uid < 1000 || uid > 60000) fail();
|
||||
check(mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL));
|
||||
check(mount(argv[3], root, NULL, MS_BIND|MS_REC, NULL));
|
||||
check(mount(NULL, root, NULL, MS_BIND|MS_REMOUNT|MS_RDONLY|MS_NOSUID|MS_NODEV, NULL));
|
||||
char path[4096];
|
||||
target(path, sizeof(path), root, "/upload");
|
||||
check(mount(argv[4], path, NULL, MS_BIND, NULL));
|
||||
check(mount(NULL, path, NULL, MS_BIND|MS_REMOUNT|MS_RDONLY|MS_NOSUID|MS_NODEV|MS_NOEXEC, NULL));
|
||||
target(path, sizeof(path), root, "/scratch");
|
||||
check(mount(argv[5], path, NULL, MS_BIND, NULL));
|
||||
check(mount(NULL, path, NULL, MS_BIND|MS_REMOUNT|MS_NOSUID|MS_NODEV|MS_NOEXEC, NULL));
|
||||
target(path, sizeof(path), root, "/tmp");
|
||||
check(mount("tmpfs", path, "tmpfs", MS_NOSUID|MS_NODEV|MS_NOEXEC, "size=16m,mode=1777"));
|
||||
target(path, sizeof(path), root, "/proc");
|
||||
check(mount("proc", path, "proc", MS_NOSUID|MS_NODEV|MS_NOEXEC|MS_RDONLY, NULL));
|
||||
target(path, sizeof(path), root, "/dev/null");
|
||||
check(mount("/dev/null", path, NULL, MS_BIND, NULL));
|
||||
check(mount(NULL, path, NULL, MS_BIND|MS_REMOUNT|MS_NOSUID|MS_NOEXEC, NULL));
|
||||
check(chdir(root)); check(chroot(".")); check(chdir("/scratch"));
|
||||
limit(RLIMIT_CORE, 0); limit(RLIMIT_FSIZE, number(argv[7])); limit(RLIMIT_CPU, number(argv[8]));
|
||||
limit(RLIMIT_NOFILE, 1024);
|
||||
/* Subordinate GID mapping keeps setgroups allowed; remove all inherited groups. */
|
||||
int groups = getgroups(0, NULL); check(groups);
|
||||
if (groups > 0) check(setgroups(0, NULL));
|
||||
for (int c = 0; c <= CAP_LAST_CAP; c++) check(prctl(PR_CAPBSET_DROP, c, 0, 0, 0));
|
||||
check(setresgid(uid, uid, uid)); check(setresuid(uid, uid, uid));
|
||||
struct __user_cap_header_struct h = { _LINUX_CAPABILITY_VERSION_3, 0 };
|
||||
struct __user_cap_data_struct d[2] = {{0}, {0}};
|
||||
check(syscall(SYS_capset, &h, d)); check(prctl(PR_SET_DUMPABLE, 0));
|
||||
filter();
|
||||
if (!strcmp(argv[9], "--check")) return 0;
|
||||
execv(argv[9], &argv[9]); fail();
|
||||
}
|
||||
77
packages/media-sandbox/native/test-child.c
Normal file
77
packages/media-sandbox/native/test-child.c
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/* Adversarial test payload, compiled statically into the disposable test rootfs. */
|
||||
#define _GNU_SOURCE
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
extern char **environ;
|
||||
static void copy(const char *path) {
|
||||
FILE *f = fopen(path, "r"); if (!f) exit(9);
|
||||
int c; while ((c = fgetc(f)) != EOF) putchar(c ? c : '\n'); fclose(f);
|
||||
}
|
||||
int main(int argc, char **argv) {
|
||||
const char *input = NULL;
|
||||
for (int i = 1; i + 1 < argc; i++) if (!strcmp(argv[i], "-i")) input = argv[i + 1];
|
||||
if (!input || strncmp(input, "file:", 5)) {
|
||||
/* Pipe-only supervisor canary: stay alive to inspect /proc, then force a failure. */
|
||||
fputs("forced authentication/connection failure\n", stderr); fflush(stderr); sleep(2); return 7;
|
||||
}
|
||||
FILE *f = fopen(input + 5, "r"); if (!f) return 8;
|
||||
char action[64], path[4096] = {0};
|
||||
if (!fgets(action, sizeof(action), f)) return 8;
|
||||
action[strcspn(action, "\n")] = 0;
|
||||
if (fgets(path, sizeof(path), f)) path[strcspn(path, "\n")] = 0;
|
||||
fclose(f);
|
||||
if (!strcmp(action, "isolation")) {
|
||||
if (!getuid() || prctl(PR_GET_NO_NEW_PRIVS, 0, 0, 0, 0) != 1) return 10;
|
||||
if (!access(path, R_OK) || !access("/etc/passwd", R_OK) || !access("/var/run/docker.sock", R_OK)) return 11;
|
||||
int fd = open("/root-write", O_WRONLY|O_CREAT, 0600); if (fd >= 0) return 12;
|
||||
fd = open("/scratch/result", O_WRONLY|O_CREAT, 0600); if (fd < 0) return 13; close(fd);
|
||||
if (!access("/upload/unmounted", R_OK)) return 14;
|
||||
printf("isolated uid=%u\n", getuid()); return 0;
|
||||
}
|
||||
if (!strcmp(action, "network")) {
|
||||
for (int n = 0; n < 3; n++) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd >= 0) {
|
||||
const char *ips[] = {"127.0.0.1", "10.0.0.1", "169.254.169.254"};
|
||||
struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(80) };
|
||||
inet_pton(AF_INET, ips[n], &addr.sin_addr);
|
||||
if (!connect(fd, (struct sockaddr *)&addr, sizeof(addr))) return 15;
|
||||
close(fd);
|
||||
} else if (errno != EPERM) return 16;
|
||||
}
|
||||
puts("network denied"); return 0;
|
||||
}
|
||||
if (!strcmp(action, "memory")) {
|
||||
puts("ready"); fflush(stdout);
|
||||
for (;;) { char *p = malloc(1024*1024); if (!p) return 17; memset(p, 1, 1024*1024); asm volatile("" : : "r"(p) : "memory"); }
|
||||
}
|
||||
if (!strcmp(action, "pids")) {
|
||||
int count = 0; pid_t child;
|
||||
while ((child = fork()) >= 0) { if (!child) { sleep(10); return 0; } if (++count > 100) return 18; }
|
||||
puts("pids denied"); fflush(stdout);
|
||||
int ok = errno == EAGAIN; return ok ? 0 : 19;
|
||||
}
|
||||
if (!strcmp(action, "sleep")) { puts("ready"); fflush(stdout); sleep(20); return 0; }
|
||||
if (!strcmp(action, "size")) {
|
||||
f = fopen("/scratch/large", "w"); if (!f) return 20;
|
||||
for (;;) { char b[4096] = {0}; if (fwrite(b, 1, sizeof(b), f) != sizeof(b)) return 21; fflush(f); }
|
||||
}
|
||||
if (!strcmp(action, "metadata")) {
|
||||
copy("/proc/self/cmdline"); copy("/proc/self/environ");
|
||||
/* Host canaries must not be visible to the child. */
|
||||
for (char **e = environ; *e; e++) if (strstr(*e, "CANARY")) return 22;
|
||||
fputs("forced authentication/connection failure\n", stderr); sleep(2); return 7;
|
||||
}
|
||||
if (!strcmp(action, "probe-good")) { puts("{\"format\":{\"format_name\":\"wav\",\"duration\":\"1.0\",\"size\":\"10\"},\"streams\":[{\"index\":0,\"codec_type\":\"audio\",\"codec_name\":\"pcm_s16le\",\"sample_rate\":\"48000\",\"channels\":2}]}"); return 0; }
|
||||
if (!strcmp(action, "probe-big")) { for (int i = 0; i < 500000; i++) putchar('x'); return 0; }
|
||||
puts("{\"format\":{},\"streams\":[],\"unexpected\":\"untrusted metadata\"}"); return 0;
|
||||
}
|
||||
1
packages/media-sandbox/node_modules
Symbolic link
1
packages/media-sandbox/node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../egress/node_modules
|
||||
15
packages/media-sandbox/package.json
Normal file
15
packages/media-sandbox/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/media-sandbox",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "commonjs",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": { ".": "./dist/index.js" },
|
||||
"scripts": {
|
||||
"build": "sh scripts/build-helper.sh && tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "sh scripts/build-helper.sh && vitest run"
|
||||
},
|
||||
"devDependencies": { "@types/node": "^22.20.5", "typescript": "^5.5.4", "vitest": "^2.0.5" }
|
||||
}
|
||||
5
packages/media-sandbox/scripts/build-helper.sh
Normal file
5
packages/media-sandbox/scripts/build-helper.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p dist
|
||||
${CC:-cc} -std=c11 -O2 -Wall -Wextra -Werror -fstack-protector-strong -D_FORTIFY_SOURCE=2 -Wl,-z,relro,-z,now native/confine.c -o dist/media-confine
|
||||
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();
|
||||
}
|
||||
}
|
||||
5
packages/media-sandbox/tsconfig.json
Normal file
5
packages/media-sandbox/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src", "module": "CommonJS", "moduleResolution": "node" },
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue