Skip to content

Security: xiabee/XCut

Security

docs/SECURITY.md

XCut Security

Threat Model

XCut processes user-supplied media files on a personal machine. Threat sources, in order of practical importance:

  1. Malformed media files — crafted videos/audios attacking FFmpeg/ffprobe parsers (memory corruption, decompression bombs).
  2. Malicious paths/names — path traversal, absolute-path escapes, symlinks, UNC/drive-letter tricks (Windows), shell metacharacters.
  3. Local network exposure — the HTTP API must not silently open the machine to the LAN (controls: D12).
  4. 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.

Attack Surface & Controls

FFmpeg / ffprobe invocation

  • 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.Context timeout 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.

Path security

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

HTTP API and uploads

  • Binds 127.0.0.1 by default (serve refuses any other bind). listen_remote: true is an explicit user act and additionally requires a bearer token — both enforced in config.Resolve and again in serveAddr before 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-For and 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/v1 route (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 an HttpOnly; SameSite=Strict session cookie — while writes still require the session id echoed in X-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.LimitReader streaming writes; Content-Length is 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.

Script injection in the embedded UI

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

Secrets

  • No secrets in git-tracked config. Env vars (XCUT_*) or a git-ignored local secrets file. .gitignore covers secrets*.json, .env*.

Telemetry

  • None. No network calls by the core. No filenames, hashes, or usage data leave the machine, ever, unless a future explicit opt-in exists.

Rust workers (future)

  • Safe Rust by default; any unsafe requires a written safety justification, tests, and minimal scope. Workers parse only structured JSON input; workers are optional — absence degrades capability, never availability.

Known Limitations (current)

  • 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 as xcut itself. 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 — serve speaks 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 serve with 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.

There aren't any published security advisories