The HLS playlists answer an anonymous request where upstream refuses one #131
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Measured during #111's review, with no
Authorizationheader at all:So the two servers disagree about whether a playlist is a public document. Ours hands out the segment URLs, the codec ladder and the media layout to anyone who knows an item id.
Read this next to what is not a divergence, because the pair is the point.
/Videos/{id}/stream?static=trueand/Audio/{id}/streamanswer 200 with the file to an unauthenticated caller on both servers — that is upstream's own behaviour (StreamingHelpers.csreads the item with no account), reproduced deliberately and recorded inMILESTONES.md's risk table. The progressive routes being open is upstream's decision; the HLS playlists being open is ours alone.The reviewer could not chain the playlist to actual bytes —
/hls1/{playlist}/{segment}404s anonymously here — so what is established is the disagreement on the playlist, not a complete anonymous playback path. The segment route's behaviour under a token it should not accept is unmeasured and worth settling in the same pass.Where to start
crates/jellyfin-http/src/routes/hls.rsand the authorization the route is declared under, againstDynamicHlsController's attributes. Note the whole controller is one upstream marks[ApiExplorerSettings(IgnoreApi = true)], so the spec describes none of it andENDPOINTS.mdcounts it separately — the attribute is the only contract, which is exactly the shape of the M7 review's finding that a controller's[Authorize]is part of the contract and nothing here was reading it.Check every route in that module while you are there, not just the two measured.
How to know it is done
An anonymous request for each HLS route answers what upstream answers, and a scenario replayed with
--anonymouscarries them — the harness already supports that spelling andcrates/jellyfin-harness/README.mdcalls an anonymous replay a diff worth having.House rules
Tests run under
cargo nextest; upstream's source is the checkout inupstream/at the pin; generated files are regenerated rather than edited; comments say what the code does, not how it got there. Anything needing a write wants its own container from the pinned image over asqlite3 .backupcopy ofreference/var/config/data/jellyfin.db, on ports of its own — the shared reference on:8096is read-only.Done, on
main— nine commits,910b672through1b6bd0f.What was actually open
Worse than the issue said. Anonymously, with no
Authorizationheader at all, the pre-change server answered:So it was not a leak that stopped at the playlist: the anonymous path ran an ffmpeg process to completion and handed back media, on two routes rather than one. The earlier 404 that suggested otherwise was an artefact of asking without the query a playlist carries.
And a wrong token was as good as no token —
Token="deadbeef…"got a 200 playlist, because the route tookOption<Authenticated>and that extractor mapsErr(Unauthorized)toOk(None).The contract
DynamicHlsControlleropens[Route("")] [Authorize] [ApiExplorerSettings(IgnoreApi = true)], and no action carries[AllowAnonymous]or a narrower policy — so the class attribute is the whole of it. The spec describes none of this controller, which makes the attributes the only contract: the same shape as the M7 review's finding that a controller's[Authorize]is part of the contract and nothing here was reading it.The legacy
HlsSegmentControlleris deliberately different and is registered as such — no class[Authorize], two of four actions authorized, with upstream's own comment saying why: some clients send requests without the full query string.Verified against the live reference
Five things the module needed beyond the two routes measured
[Required]—runtimeTicksandactualSegmentLengthTicks— and we served without either.ApiErrorgrew a complaint list, and the validation bodies are byte-identical after droppingtraceId.Error processing request.HEADis declared on two actions only, so four of the six answer 405 withAllow: GET.The login door
admitruns the access-schedule check third, whereUserManager.cs:598runs it — after the disabled account and remote access, before the credential.A schedule that does not gate the login gates nothing, because the token outlives the window either way. The rest — remote access and the schedule on every authorized request, which upstream's
DefaultAuthorizationHandlerdoes — is #134, reopened with its measurements: it changes all 253 registrations, needs a policy notion this port lacks so the twoIgnoreParentalControlactions can skip the check, introduces a 401/403 split, and cannot be replayed until the fixture grows a restricted account.An instruction that was wrong, and the measurement that settled it
I asked for a garbage credential to be refused wherever no credential would be. Upstream does not do that — it populates an unauthenticated identity and lets the action decide:
Ours matches all three. Propagating the error out of
OptionalFromRequestPartswould have invented a divergence acrossimages,library,localization,systemandattachments. The fix was requiringAuthenticatedon the one route that should never have been optional.The master playlist rewrites the codec it handed over
DynamicHlsHelper.cs:154-160: compare the chosen output audio codec against the raw query value, and rebuild the query only when they differ. Ours passed the request's query through verbatim, so a client asking foraacon a source whose audio can be copied was told to fetchaudioCodec=aacwhere upstream sayscopy. Fixed, andhls-master-playlistis inserving.jsonagreeing byte for byte — it was withheld before precisely because it would have been red on arrival.One value still differs, named rather than buried: a request naming no audio codec gets
AudioCodec=m3u8from upstream, because the state infers the codec from the request's own container extension and the URL ends in.m3u8. That is negotiation rather than playlist rendering, it differed before this change too, and it likely reaches the ffmpeg arguments — so it wants settling there rather than a special case in the playlist.Numbers
JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspaceonmain: 2203 passed, 0 skipped. Clippy clean.Replays:
refusals.json --anonymous143 responses 0 differences andserving.json --anonymous54 responses 0 differences, both against the shared reference;serving.jsonauthenticated against a private pair, 54 responses 0 differences, with all four HLS playlist entries agreeing on body as well as status.A sweep of all 253 route registrations across 44 files, each handler resolved against upstream's attribute, found no other module serving media or metadata where upstream requires a caller. HLS was the outlier.
Two things left behind: #136 for every route answering HEAD where upstream answers 405, which is port-wide and wants upstream's
[HttpHead]declarations extracted by thextask endpointsscanner, and #134 for the per-request handler.Not verified:
live.m3u8with a token — upstream never answered within 60 s on a fixture with no tuner, so only its anonymous 401 is measured.