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.
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:
sessionIDis restricted to a safe regex and is a server-issued UUID v4 (122-bit entropy, not enumerable in practice)./healthno 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
/healthto 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":
- Generate
streamToken = crypto.randomBytes(32).toString("hex")server-side (NOT browser, so it never lives in client storage longer than the page lifetime). - Persist
(taskID, streamToken, expiresAt)indownload_task(new columns or a sibling table). Token expires e.g. 6 h after issue or on explicit revoke. - Push the token to the daemon over the existing heartbeat / sync
channel that already carries
streamRequested. Add astreamTokenfield next to it. The daemon trusts that channel (it is authenticated agent ↔ origin). - 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 withstreamToken.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 storestreamTokennext tostreamRequested.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]stringguarded by mutex. SetTaskToken(taskID, token)andClearTaskToken(taskID).handler(/stream) extracts?id=and?t=, checks the token withsubtle.ConstantTimeCompare; 404 on mismatch.hlsHandler(/hls/<sessionID>/...) needs an HLS-session → token mapping, since the path carriessessionIDnottaskID. Store the token on theHLSSessionat start and validate per request.
- New field
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
- Token TTL. 6 h gives plenty of room for a movie + pause + resume; longer means the post-leak window is wider.
- Where to store the token in
download_task— same row, or a siblingdownload_stream_tokentable that we can rotate without writing to the task row. - Should
/playlist.m3u(VLC) embed the token directly, or use a one-shot redeem URL? VLC URL ends up in history. - Token reuse across HLS reconnects — yes, scoped to the
HLSSession, invalidated onClose(). - Do we want a daemon flag
--require-stream-tokenindependent 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.