Authorization runs after the body is bound, so a caller who may not reach a route is told what was wrong with their body #142
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?
POST /Backup/Createas an authenticated non-administrator, measured on the pair:Content-Type: text/plain,xContent-Type: application/json,{Content-Type: application/json,{}Upstream declares
[Authorize(Policy = Policies.RequiresElevation)]on the controller, and ASP.NET runs an authorization filter before model binding — so a caller who may not reach the action is refused whatever they send, and the body is never looked at.Ours checks authorization in the handler body.
axumrunsFromRequestPartsextractors first and the singleFromRequest(the body) last, soJson<T>has already rejected the request beforeelevated(&context, &caller)is reached. The anonymous case is fine —Authenticatedis aFromRequestPartsand still answers401first — so this is only ever visible to a caller who is authenticated and is not permitted.It is not the backup routes
Found while reviewing the backup work; the shape is the pattern the whole crate is written in. Twenty handlers bind a body and then check authorization inside the handler:
Some are worse than a status code.
library/images/writing.rs::storeandset_splashscreenread an uploaded picture into memory before asking whether the caller may upload one, andadmin/configuration.rs::updatedeserializes a whole configuration document first. An account with a token but no permission can make the server do that work, repeatedly.Where to start
The fix is not per-route. Two shapes worth weighing, and the choice wants an argument in
DECISIONS.mdbecause it changes how every write route is written:Elevatedis aFromRequestPartsbesideAuthenticatedand runs before any body — theelevated(&context, &caller)call disappears from the handler. Closest to upstream's[Authorize(Policy = …)], and the type says what the route needs.Either way
AppContexthas to be reachable from the extractor or the layer, which it is — it is already the router's state.crates/jellyfin-http/src/auth.rsis whereelevatedlives now, andcrates/jellyfin-http/tests/common/mod.rsbuilds the router the route tests drive.How to know it is done
An authenticated non-administrator posting an unreadable body to a route they may not reach is answered
403, and the body is never read.refusals.jsonis where it belongs — it already replays anonymously and as the administrator, and the account it needs isrestricted, whichvisibility.jsonalready logs in as.Worth measuring the same three bodies against every one of the twenty, not just the one this was found on: the statuses ours gives now are the binder's and vary with the extractor.
House rules
Tests run under
cargo nextest; upstream's source is the checkout inupstream/at the pin; generated artifacts are regenerated, never edited; comments say what the code does, not how it got there. The shared reference on:8096is read-only — logging in asrestrictedis a write, so anything beyond a refusal wants its own container from the pinned image over acp -acopy of a snapshot, on its own ports. Checkss -lntpfor a free port before binding.Closed by
c9137c8, with the review's findings in551d87d.The extractor won, and the argument is
DECISIONS.mdD32. Every policyupstream declares is a type implementing
FromRequestParts—Authenticated,Anonymous,Elevated,FirstTimeSetupOrElevated,FirstTimeSetupOrDefault,IgnoreParentalControl,LocalOrElevated,CollectionManagement,Download,SubtitleManagement— and a handler names the one its action is declared underand calls nothing.
Two things the issue did not anticipate:
POST /Backup/Createasrestrictedis403with no content type at all, where the403an actionthrows through
SecurityExceptioncarries upstream'stext/plain "Error processing request.". That is a second error variant,ForbiddenEmpty, andsix other sites were on the wrong one — every action returning
Forbid().bind is also a
FromRequestParts, soPath<(String, String, i32)>declaredahead of the policy answered
400for a non-numeric image index before thecaller was looked at. Four handlers had it.
Beyond the twenty:
/System/ActivityLog/Entrieswas answering any caller whereupstream is
RequiresElevation,Items/{id}/Downloadwas the last unconvertedUserPermissionRequirement, and the subtitle upload refused an administratorthat upstream admits — the permission handler alone would refuse one, and the
default handler beside it succeeds the same requirement for the administrator
role first.
Measured:
backup.jsonreplays the issue's three bodies asrestricted—13 responses, 0 differences, all three
403.refusals.jsonwent from 8differences to 2, and the two left are the pre-existing
/MusicGenres/InstantMixcontent type.