Skip to content

Serve HLS segments to clients that authenticate with a token - #302

Merged
tripsittr merged 1 commit into
mainfrom
hls-for-native-clients
Oct 6, 2026
Merged

tripsittr merged 1 commit into
mainfrom
hls-for-native-clients

Conversation

@tripsittr

Copy link
Copy Markdown
Owner

Tracker #514. Reported on The Midnight Gospel: every episode but the first starts, runs the clock, shows the right duration — and stays black.

Why

AVURLAssetHTTPHeaderFieldsKey applies to the playlist request only. AVFoundation does not propagate those headers to the individual .ts fetches, so a segment arrived with no credentials at all. The segment route sat behind session auth, which answered 302 → /login, and AVPlayer followed the redirect and decoded the login page as video.

The duration and clock worked because the playlist had carried the header. That is what made it look like a video problem rather than an auth one.

Measured directly rather than inferred:

segment WITHOUT auth header: HTTP 302 -> https://…/login

The fix

A token client gets signed segment URLs. The signature travels in the URL — the one credential a player reliably keeps — and is unguessable, scoped to one session and file, and expires after 12 hours.

The session id cannot do that job, which is worth stating because the shortcut is tempting. It is a deterministic sha256 of (item, updated_at, height, start), so anyone who knows the item can compute it. There is a test asserting it is stable, precisely so it is never later mistaken for a secret.

Browsers are untouched: a cookie rides along on every segment, so the web routes keep plain URLs and signing would be noise. The switch is keyed on the request reaching an api/* route, not on the guard.

Also fixes the other half: /playback was handing token clients the web playlist URL.

Testing

8 new tests, 290 pass across the streaming and API suites. Every one confirmed to fail against the bug it guards:

Mutation Caught by
playback points back at the web playlist an_unplayable_file_is_sent_to_the_api_playlist
signed middleware removed unsigned + tampered + expired (3 tests)
segments not signed in the playlist an_api_playlist_names_signed_segments

The unsigned case asserts specifically that the refusal is not a 302 — a redirect is the thing that broke this.

For review

The 12-hour expiry is a judgement call. Long enough to outlast any viewing including pauses; short enough that a URL in a log is not useful forever. If you would rather it were tighter, the tradeoff is a long pause mid-film failing to resume.

Not verified end-to-end. There is no transcodable video on this machine, so I could not watch a real MKV play through to confirm the picture appears. The auth path is proven by test; the pixels are not. That is the one thing worth checking on a5.

Transcoded video played black on iOS: the clock ran and the duration was right
but there was no picture.

AVURLAssetHTTPHeaderFieldsKey applies to the playlist request only, and
AVFoundation does not propagate those headers to the .ts segment fetches, so a
segment arrived with no credentials. The segment route sat behind session auth,
which answered 302 -> /login; AVPlayer followed the redirect and decoded the
login page as video. The duration worked because the playlist had carried the
header, which is what made it look like a video problem.

A token client now gets signed segment URLs: the signature travels in the URL,
which is the one credential a player reliably keeps, and it is unguessable,
scoped to one session and file, and expires. The session id cannot do that job
-- it is a deterministic hash of (item, updated_at, height, start) -- and there
is a test asserting its stability so nobody mistakes it for a secret.

The playback endpoint also pointed at the web playlist, which was the other
half of the same mistake. Browsers are unaffected: a cookie rides along on
every segment, so the web routes keep plain URLs.
@tripsittr

Copy link
Copy Markdown
Owner Author

Reviewed on a5 — clean. 8 tests pass. Based on current main, no migration.

This is the real root cause of the Midnight Gospel black-video finding, and it's server-side — my earlier call (not the file; identical to the working E01) was right, but I leaned 'app bug' when the mechanism is here: HLS .ts fetches don't carry AVURLAssetHTTPHeaderFieldsKey, so a header-less segment hit session auth → 302 → /login, and AVPlayer decoded the login page as video. Good catch.

The fix is the correct Laravel pattern:

  • Token clients get signed, expiring segment URLs via URL::temporarySignedRoute(); the /hls/{session}/{file} route is behind the signed middleware, so a segment serves on a valid signature rather than a session cookie AVPlayer can't send.
  • Signed URLs also close traversal: an attacker can't forge a signature for a ../ {file}, so the segment path can't be steered outside the HLS cache.
  • Scoped to token clients via signsSegments() — the cookie-based web player is untouched.

Nothing blocking. Note this pairs with #301 (playlist decision) — together they make native HLS actually play; the iOS app still has to request /items/{item}/playback and use the returned HLS URL. (Not installed — install-on-request.)

@tripsittr
tripsittr merged commit 01eb814 into main Oct 6, 2026
3 checks passed
@tripsittr
tripsittr deleted the hls-for-native-clients branch October 6, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant