XCut processes user-supplied media files on a personal machine. Threat sources, in order of practical importance:
- Malformed media files — crafted videos/audios attacking FFmpeg/ffprobe parsers (memory corruption, decompression bombs).
- Malicious paths/names — path traversal, absolute-path escapes, symlinks, UNC/drive-letter tricks (Windows), shell metacharacters.
- Local network exposure — the HTTP API must not silently open the machine to the LAN (controls: D12).
- Supply chain — dependencies (Go modules, Rust crates) and the FFmpeg build itself.
Trusted: the XCut binary, its config files (user-controlled), the local OS user (and therefore every process running as it — which is why loopback peers are trusted). Untrusted: every media file, every filename, every uploaded byte, and every peer that reaches the API from off-box.
- Always
exec.CommandContext(bin, args...)— never a shell (sh -c,cmd /c) and never string-concatenated commands. User text can only ever be a single argument value. context.Contexttimeout on every external call; child process killed on cancel; process group cleanup where supported.- Concurrency capped by config (
resource.max_ffmpeg_processes), threads capped via-threads. - FFmpeg is treated as an untrusted decoder: it runs as the same (user) process,
crashes are caught and surfaced as
ffmpeg_failure, never fatal to the core.
- All project/asset paths resolve through a safe-join helper that rejects
absolute inputs,
..escapes, and (where detectable) symlink escapes outside the workspace root. - Windows specifics handled: drive-letter absoluteness, UNC paths, reserved device names.
- Asset files may live anywhere on disk (user's videos) — import records them; project outputs never leave the workspace.
- Binds
127.0.0.1by default (serverefuses any other bind).listen_remote: trueis an explicit user act and additionally requires a bearer token — both enforced inconfig.Resolveand again inserveAddrbefore the socket opens (D12). - Non-local peers must send
Authorization: Bearer <token>; the token is compared in constant time, failures are reported as a bare 401 (no hint how close an attempt was) and counted per peer — 20 rejections per 5 minutes, then 429 — so the gate cannot be brute-forced and cannot grow its tracking state without bound. - Peer trust is decided from the socket's own
RemoteAddr.Host,X-Forwarded-Forand similar are attacker-chosen and never consulted. - Loopback peers are trusted without a token, which is what keeps the desktop client and the double-clicked exe zero-config. Deliberate trade-off: any local process (and any user account on that machine) can reach the API. The threat model already treats the local OS user as trusted.
- The embedded UI shell is served unauthenticated; every
/api/v1route (including the media preview, render/subtitle download and the installer trigger) is not. Browsers cannot attach a header to media URLs, so a remote UI logs in once (POST /api/v1/session, proven by the bearer token) and then reads with anHttpOnly; SameSite=Strictsession cookie — while writes still require the session id echoed inX-Cut-Session, which a cross-site page cannot produce. Verified in a real browser: a media fetch with no header at all succeeds on the cookie, the same-origin mutation without the echo is refused (D14). - Sessions are in-memory, TTL 12 hours, capped at 256 (past the cap a login is
refused rather than the table growing), revoked by
DELETE /api/v1/session, and cleared by a restart — deliberately not persisted. - Upload size enforced with
http.MaxBytesReader/io.LimitReaderstreaming writes;Content-Lengthis not trusted alone. - File type decided by real probing (ffprobe), never by extension or Content-Type.
- No CORS headers are sent: the UI is same-origin by construction, and a wildcard would hand the loopback-trusted API to any page the operator opens.
The UI is one HTML file plus static/app.js, served same-origin to every peer the
session posture admits — including a remote machine holding a valid token (D14). A
script injected into that page therefore reads the workspace through an
authenticated session, which no other control in this document would catch. The one
thing standing between the two is a rendering rule: server data reaches the DOM as
text, never as markup.
That rule is enforced, not trusted. TestUINeverWritesMarkupFromAString and
TestUINeverBuildsCodeFromStrings scan the shipped scripts for innerHTML/
outerHTML assignment, insertAdjacentHTML, document.write, eval,
new Function and the string forms of setTimeout/setInterval, and accept only
the empty-string assignment the code uses to clear a node. TestTheScannersNoticeWhatTheyGuard
runs the same classifier over injected violations, so a scanner that stopped matching
anything cannot report a clean UI — and both guards were checked against a real
injection into app.js before it was reverted.
What is not covered: the rule is about how the page renders, not about whether the markup it renders is well-formed, and it does not constrain a future feature that intends to render HTML (that would need its own sanitiser and its own argument here).
- No secrets in git-tracked config. Env vars (
XCUT_*) or a git-ignored local secrets file..gitignorecoverssecrets*.json,.env*.
- None. No network calls by the core. No filenames, hashes, or usage data leave the machine, ever, unless a future explicit opt-in exists.
- Safe Rust by default; any
unsaferequires a written safety justification, tests, and minimal scope. Workers parse only structured JSON input; workers are optional — absence degrades capability, never availability.
- FFmpeg parses untrusted media in-process with full user rights. What is applied
is a resource boundary, not a filesystem or syscall one: every child joins a
kill-on-close job object on Windows (since session #8) and, on Linux, starts in
its own systemd scope capped with
MemoryMax(D18) — so a parse that balloons cannot take the machine with it. What is not applied: no seccomp filter, no container, no filesystem or network isolation around the child, so an FFmpeg vulnerability that reaches for files or sockets sees the same rights asxcutitself. Mitigations today: timeouts, concurrency and byte ceilings, and the loopback-only bind. Future hardening: the container rung in ROADMAP Phase 4. - No HTTP API auth beyond the static bearer token (D12): there is no
per-client identity, no rotation or revocation surface (revoke = edit the
config and restart), and no TLS —
servespeaks cleartext HTTP. A remote bind therefore belongs on a trusted network or inside a tunnel (Tailscale, WireGuard, SSH), never on a raw untrusted LAN: the bearer token crosses the wire in plaintext and is the only thing between a passive attacker and the whole workspace. The concrete runbook — including what a tunnel does and does not authenticate — is in OPERATIONS.md. - Do not front
servewith a same-machine reverse proxy. Loopback peers are trusted by design, so a TLS terminator on 127.0.0.1 would present every remote client as local and silence the gate entirely. Terminating TLS in front of this API requires first making trust provable through the proxy (a signed header or a unix socket), which is not built. - The failure tracker and its budget live in one process: a restart resets a peer's lockout. Bounded, in-memory, and deliberately not persisted.