T-HUB/packages/media-sandbox/README.md
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

38 lines
8.7 KiB
Markdown

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