A read by id ignores what the caller is allowed to see #111
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?
Named in
MILESTONES.md's risk table and not yet actionable anywhere else, so filing it as work.Upstream reaches an item through
GetItemById<T>(id, userId), which appliesIsVisibleStandalone: blocked tags, blocked media folders, and the parental rating. This port has no equivalent at any of its by-id call sites — visibility exists only insideItemQueryand in which libraries a view offers. A caller who knows an id reads the row whatever their account allows, including its media source and therefore its stream.Three pieces, in the order they matter:
InheritedParentalRatingValueis populated since M11 and the query filter honours it; extending it to a by-id read is the smallest half and closes the case an operator most expects to work — a child account opening a link to an adult film.This is the one item on the outstanding list that is a security property rather than a feature, and it is worth a test per call site class rather than a single guard somebody can forget to call — the M7 review found four routes running under a weaker policy than upstream's for exactly that reason.
Note the separate, deliberate divergence already recorded in the same table: an anonymous
GET /Genres/<name>/Images/Primaryfiles a row, which is upstream's own behaviour and is named rather than fixed. Do not conflate the two.Where to start
MILESTONES.md's risk table has the statement of the problem; this is the work.Upstream reaches an item through
GetItemById<T>(id, userId)and the rule isBaseItem.IsVisibleStandalone— blocked tags, blocked media folders, and theparental rating. Read that method and the
Userpolicy fields it consultsbefore touching anything here.
Ours: the filters exist only inside
crates/jellyfin-core/src/query.rsand inwhich libraries a view offers. The by-id call sites are found with a grep for
the item-by-id helpers in
crates/jellyfin-http/src/routes/andcrates/jellyfin-app/— there were 96 at the time this was written, so theshape of the fix matters more than the fix: a guard somebody has to remember
to call will be forgotten, and the M7 review already found four routes running
under a weaker policy than upstream's for exactly that reason. Prefer making the
unchecked read impossible to express — the user is a parameter of the fetch, not
a check after it.
Order: the parental rating first (it is populated since M11 and the query filter
already honours it, so this is the smallest half and closes the case an operator
most expects to work), then blocked tags, then blocked media folders.
How to know it is done
shown in a listing — and the streaming routes are in that set, not just the
metadata ones.
a case the fixture has to grow a user for.
GET /Genres/<name>/Images/Primaryfiling a row is upstream's own behaviour,reproduced on purpose.
House rules
CLAUDE.mdis the short version and these are the parts that bite: tests rununder
cargo nextest; upstream's source is the checkout inupstream/at thepin (
v12.0-rc5,6d501ba4) and not the clone beside the repository;generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and
Dnreferences stay out of code and comments.ARCHITECTURE.mddecides which crate a thing goes in and which dependency edgesare illegal,
DECISIONS.mdsays why a rule exists.The reference pair is
cargo xtask reference up(andreference provision,which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,with
diff-dbfor the two databases.crates/jellyfin-harness/README.mdsayswhat a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.
Done, on
main— twelve commits,d44b0e1throughf7226dd.The shape
ItemRepository::by_id(id, seen_by: &Viewer).Vieweris either an account — rating limit, cleaned blocked tags, refused libraries — orViewer::anyone(), the read with no account behind it. Roughly sixty call sites each had to say whose read it is, andanyone()is greppable: 43 production sites, every one checked against where upstream passes no user. Refusal is absence rather than a check bolted on afterwards, so every site got upstream's status and body for free — the same 162-byte problem document, byte for byte.by_idstakes aViewertoo, after review:ARCHITECTURE.mdclaimed an unchecked by-id read could not be expressed, and that method was one, with six callers.Measured, not argued
Three restricted accounts —
MaxParentalRating,BlockedTags,EnabledFolders— against an upstream container stood up from the pinned image over a copy of the reference database, because the shared reference has one account and creating another there is a write. 23 by-id routes × 3 accounts agree with upstream on status and body, images included:What the review found, and what it cost
A listing aimed at a playlist or a box set applied none of the three restrictions. The linked-contents branch skipped the narrowing entirely, so a PG-capped account opening a collection an adult had added a restricted film to received that film's whole
BaseItemDtowhere upstream answers 401. The change's own doc says a row reached by its id is a row the account would have been listed; this inverted it. Fixed, andItemQuerygrewblocked_librariesfor that one listing — what a set names can sit in any library, where an ordinary listing is already narrowed to what it may select from, which is why/Items?ids=keeps #91's behaviour untouched. The recursive form keeps the folder check off, because that is upstream's own exception./Items/Latestreturned R-rated titles to a capped account while a doc comment added by this change claimed it narrowed. Now identical to upstream in titles and order for all four accounts.A box set in a refused library was over-refused — upstream 200, ours 404. Fixed rather than exempted: upstream's
IsLegacyBoxSetis a path test, so a box set with no path, a path under%AppDataPath%, or linked children is not measured by the library its row sits in. Both kinds are pinned by a test.The scenario was almost all refusals — 18 of 26 requests 404-from-both, and one intended positive control vacuous because that fixture row has no image. A regression returning
Nonefor every restricted account would have diffed clean across most of it, which is M5's lesson exactly. It is 38 requests now: 19 refusals and 19 answered with a body through the library the account keeps.Two things deliberately not done
The progressive stream routes are not guarded, and that is correct:
GET /Videos/{id}/stream?static=trueand/Audio/{id}/streamanswer 200 with the file to a caller holding no token at all — verified on the reference with noAuthorizationheader, and it isStreamingHelpers.csreading the item with no account. A check there would protect nothing while the anonymous door stands open, and it would break the "same status and same body" bar this change is measured by./Videos/{id}/master.m3u8answering anonymously is ours rather than upstream's, and is filed as #131.#91's hole stays open. A listing naming
idsstill reaches rows outside the caller's libraries on both servers — confirmed both ways, rating and tags do narrow it on both, folders do not on either.The risk row
Not retired — restored, with
Half-corrected at M11.appended in the M8 style. What reaches a by-id read is closed; what is left is #128 (every listing other than/Itemsand/Items/Lateststill ignores the account's limits) and #129 (AllowedTagsandBlockUnratedItemsare modelled nowhere). An account can still read what it must not see, through those listings, and the record says so.Cost
/Items/{id}went 20 → 23 queries for an ordinary account and 26 for one refused four libraries, after batching the folders behind refused libraries into one query and reading the account's rows once per viewer instead of four times. Interleaved A/B over 800 requests: +0.07 ms ordinary, +0.23 ms restricted. Every added read is a primary-key lookup or one indexedAncestorIdsquery — O(1) in library size, againstreport.md's 22 ms median on 56,337 items. A cache was considered and rejected: it would need invalidating on every policy and library write.Numbers
JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspaceonmain: 2167 passed, 0 skipped. Clippy clean.visibility.json38/38 agreed, 0 differences. Against a control run of the pre-change binary:refusals21 → 9 differences, andbrowsing,items,portable,collections,serving,recommendations,handshakeandplaybackall unchanged.Browser:
folderkidsees only the movies library, and a direct URL to an item in a refused library never renders a detail page.jellyfinis unaffected — all four libraries, artwork, item detail with stream info.One caveat about running the new scenario.
visibility.jsonneeds arestrictedaccount that onlycargo xtask reference provisioncreates, and that provisioning change is in this landing. Until the reference is re-provisioned the scenario fails loudly — exit 1, no output directory, zero requests sent — which is the right failure, and was checked rather than assumed.Four defects this turned up are filed: #128, #129, #130, #131.