unarr/Docs/plans/security-stream-token.md
Deivid Soto 060a3e48db fix(security): CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.

Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.

URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.

State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.

ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.

Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.

Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
2026-05-15 18:48:59 +02:00

5.4 KiB

Phase 2.2 — Per-task stream token (deferred)

Status: deferred. Requires coordinated change in the web app (torrentclaw-web) and the CLI daemon. Pulled out of the Phase 2 security pass because the CLI-only fixes (UPnP opt-in, SSE caps, signed self-update) ship without web-side work; the stream-token work cannot.

Problem

/stream, /playlist.m3u and /hls/<sessionID>/... on the daemon HTTP server have no authentication. Today, anyone who can reach the listener and guesses (or learns) the taskID (for /stream) or sessionID (for /hls) can fetch the active file.

Mitigations already in place after Phase 1+2:

  • sessionID is restricted to a safe regex and is a server-issued UUID v4 (122-bit entropy, not enumerable in practice).
  • /health no longer leaks the active filename, taskID prefix or client IP to remote callers (loopback diagnostics preserved).
  • UPnP is opt-in, so by default the daemon is not exposed to the public internet.
  • The web client probes /health to pick LAN vs Tailscale.

Residual risk:

  • On a shared LAN (open Wi-Fi, office network, dorm) any device can reach the listener and brute-force ?id=<taskID> against /stream. taskIDs are also UUIDs, so this is high entropy, but the URL may leak through browser history, sharing, screen capture or a passive logger and there is no second factor.
  • A user who explicitly opts into UPnP exposes the same surface to the entire internet.

A per-task secret carried in the URL closes this without breaking the <video src> flow (the browser cannot attach Authorization headers to media elements, but it can append a query parameter).

Design

Both ends agree on a per-task secret token. The web generates it when the user requests streaming; the daemon receives the (taskID, token) pair and validates the token on every /stream and /hls/... request.

Web side (torrentclaw-web)

When the user clicks "Stream":

  1. Generate streamToken = crypto.randomBytes(32).toString("hex") server-side (NOT browser, so it never lives in client storage longer than the page lifetime).
  2. Persist (taskID, streamToken, expiresAt) in download_task (new columns or a sibling table). Token expires e.g. 6 h after issue or on explicit revoke.
  3. Push the token to the daemon over the existing heartbeat / sync channel that already carries streamRequested. Add a streamToken field next to it. The daemon trusts that channel (it is authenticated agent ↔ origin).
  4. Include the token in the stream URLs the API returns to the browser: http://<host>:<port>/stream?id=<taskID>&t=<streamToken> and the /hls/<sessionID> URLs gain ?t=<streamToken> too.

Files that will need to change:

  • src/lib/services/agent.ts — extend the stream-request payload with streamToken.
  • src/lib/db/schema.ts — column / table for the token.
  • src/lib/services/stream-resolve.ts — append &t= to the URLs it builds.
  • src/lib/stream-probe.ts — keep probing /health (no token), then append &t= to the winning stream URL before returning.
  • src/middleware.ts — no CORS change required (browser still hits daemon directly).

CLI side

  • internal/agent/types.go / internal/agent/sync.go — accept and store streamToken next to streamRequested.
  • internal/agent/daemon.go — when the heartbeat reports a new active stream task, push the token into the stream server via a setter: streamSrv.SetTaskToken(taskID, token).
  • internal/engine/stream_server.go:
    • New field tokens map[string]string guarded by mutex.
    • SetTaskToken(taskID, token) and ClearTaskToken(taskID).
    • handler (/stream) extracts ?id= and ?t=, checks the token with subtle.ConstantTimeCompare; 404 on mismatch.
    • hlsHandler (/hls/<sessionID>/...) needs an HLS-session → token mapping, since the path carries sessionID not taskID. Store the token on the HLSSession at start and validate per request.

Backwards compatibility

  • The daemon must accept token-less requests for one minor version so a newer daemon can still serve an older web (and vice-versa). Gate the check on a config flag (require_stream_token, default false in the first release, default true in the next).
  • The <video src> form supports query parameters, so the only user-visible change is the URL string.

Open questions to resolve before implementing

  1. Token TTL. 6 h gives plenty of room for a movie + pause + resume; longer means the post-leak window is wider.
  2. Where to store the token in download_task — same row, or a sibling download_stream_token table that we can rotate without writing to the task row.
  3. Should /playlist.m3u (VLC) embed the token directly, or use a one-shot redeem URL? VLC URL ends up in history.
  4. Token reuse across HLS reconnects — yes, scoped to the HLSSession, invalidated on Close().
  5. Do we want a daemon flag --require-stream-token independent of config, for users to flip on quickly without editing TOML?

Effort estimate

  • CLI: ~3 h
  • Web: ~3 h
  • Migration + rollout (config flag flip): 1 release cycle of soak.

Why not now

  • Cross-repo coordination raises commit blast radius beyond what the Phase 2 security pass should carry.
  • Web work needs DB migration + UI surfaces (the "stream link expired" path).
  • Phase 2 hardenings ship value today without it; this is the defense-in-depth layer on top.