T-HUB/packages/media-sandbox
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00
..
native Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
scripts Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
src Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
node_modules Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
package.json Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
README.md Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
tsconfig.json Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00

@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.