fix(security): short-lived signed tickets for stream + WebSocket auth (#30)#79
Merged
Merged
Conversation
#30) The long-lived session token used to ride /stream, /preview-stream and the WebSocket handshake as a ?token= query param, so it leaked into proxy/access logs, browser history, and shared links — one log line was a full session hijack (issue #30 / review #10). Add HMAC-signed, URL-safe access tickets minted from a session-authenticated request and scoped narrowly: - app/utils/tickets.py: mint/verify helper. A ticket is base64url(payload).base64url(hmac_sha256(payload)) over compact JSON {scope, user_id, video_id?, exp}, 5-minute TTL, constant-time verification (hmac.compare_digest). Rejects tampered/expired/wrong-scope/wrong-user/ wrong-video. - Stable signing secret: read from settings.stream_ticket_secret (env STREAM_TICKET_SECRET); when unset, generate once and persist under config_path via an atomic hard-link claim so it is stable across restarts and shared by all workers (never a per-process random value). Documented in .env.example. Mint endpoints (session-authenticated): - POST /api/videos/{id}/playback-ticket -> {ticket, expires_in} (stream scope, bound to the user + that video) - POST /api/auth/ws-ticket -> {ticket, expires_in} (ws scope, bound to the user) Acceptance is additive and backward-compatible: /stream, /preview-stream and /ws/{user_id} check a ticket first, then fall back to the existing ?token= session param (or X-User-Token header for streams). No client break; clients switch to tickets in a follow-up. Tests: tickets crypto (roundtrip, wrong scope/user/video, expired, tampered, malformed, secret rotation), mint endpoints (auth required, unknown video 404), stream/preview-stream over ?ticket= (incl. ranges) with expired/wrong-video/ tampered rejected and ?token= still working, and WS accepting a valid ws-ticket, 4401ing a bad one, and still honoring ?token=. Full suite: 224 passed; ruff check + format clean. No schema change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Move the long-lived session token out of
/stream,/preview-stream, and the WebSocket handshake URL query strings (where it leaks to proxy/access logs, history, and shared links) into short-lived HMAC-signed tickets (roadmap LATER tier, #10 / issue #30). Additive / backward-compatible — the old?token=keeps working during transition; clients switch in a follow-up. No migration.Tickets (
app/utils/tickets.py)base64url(payload).base64url(hmac_sha256(payload)); compact payload{scope, user_id, video_id?, exp}. Signature verified first, constant-time (hmac.compare_digest) before any field is trusted; rejects tampered/expired/wrong-scope/wrong-user/wrong-video (with abool-vs-intexp guard). TTL 5 min. Scopesstream(video+user) andws(user) — a ws ticket can't be replayed on/stream.STREAM_TICKET_SECRETenv if set, else a secret generated once and persisted underconfig_pathvia an atomic hard-link claim (concurrency-safe across workers, 0600). Never per-process-random, so worker A's ticket verifies on worker B.Endpoints
POST /api/videos/{id}/playback-ticket→ stream ticket;POST /api/auth/ws-ticket→ ws ticket. Both return{ticket, expires_in}./stream+/preview-stream: check?ticket=(verify scope+video) first, else fall back to?token=/X-User-Token.?ticket=(verify scope+user) first, else fall back to?token=.Verification (local)
pytest -q→ 224 passed (+test_stream_tickets.py: mint→stream works, expired/tampered/wrong-video/wrong-user rejected, WS accepts a valid ws-ticket and still 4401s a bad one, and?token=still works on stream + WS).ruffclean · no migration.Follow-up: switch the Flutter + tvOS stream/WS URL builders to fetch a ticket. The old query token still works until then.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RXMKM1rDWn8wNh93MMUtxY