Per-request authorization stops at the token: neither remote access nor the access schedule is checked #134

Closed
opened 2026-08-14 06:47:47 +02:00 by Claude · 3 comments
Collaborator

Upstream runs DefaultAuthorizationHandler on every [Authorize] request, and it fails the request in two cases beyond "the token is valid":

  • the user lacks EnableRemoteAccess and the request did not come from the local network, and
  • the parental access schedule disallows the current moment.

This port resolves the token to a session and stops (crates/jellyfin-http/src/auth.rs). EnableRemoteAccess is checked only at login (crates/jellyfin-app/src/session/auth/mod.rs:116), and the access schedule is checked nowhere at all.

Two concrete consequences, on every authorized route rather than any one of them:

  • A token minted from the local network and then replayed remotely is admitted here and refused upstream. The check at login does not survive the token.
  • A child account whose schedule allows 16:00–18:00 is admitted at midnight, for as long as its token lives.

AccessSchedules is one of the 31 tables this project binds, and M7 wrote the user-management routes that maintain it, so the rows exist and are served — nothing reads them when a request arrives.

Read off upstream's source rather than measured. Confirming it needs a restricted account on the reference, which is a write to the shared fixture; #111 established the pattern for that — stand up a private container from the pinned image and make the accounts there.

Where to start

crates/jellyfin-http/src/auth.rs is where a request becomes a caller, and it is the one place that can enforce this without 96 call sites remembering to. Upstream: Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs, and the NetworkManager call it uses to decide what "local" means — which this port models thinly, so what counts as the local network is its own question worth settling from IsInLocalNetwork rather than assumed to be loopback.

Note the shape #111 chose for by-id reads and prefer it here: make the unchecked path impossible to express rather than adding a guard each route must remember.

How to know it is done

A restricted account's token, replayed from outside the local network and outside its schedule, is refused with the same status and body upstream gives — measured on a private pair, and carried by a scenario so a replay holds it.

House rules

Tests run under cargo nextest; upstream's source is the checkout in upstream/ at the pin; generated artifacts are regenerated, never edited; comments say what the code does, not how it got there. The shared reference on :8096 is read-only — a login is a write, so anything needing an account wants its own container from the pinned image over a sqlite3 .backup copy of reference/var/config/data/jellyfin.db. Check ss -lntp for a free port before binding; stale servers from earlier work are still listening.

Upstream runs `DefaultAuthorizationHandler` on **every** `[Authorize]` request, and it fails the request in two cases beyond "the token is valid": - the user lacks `EnableRemoteAccess` and the request did not come from the local network, and - the parental **access schedule** disallows the current moment. This port resolves the token to a session and stops (`crates/jellyfin-http/src/auth.rs`). `EnableRemoteAccess` is checked **only at login** (`crates/jellyfin-app/src/session/auth/mod.rs:116`), and the access schedule is checked nowhere at all. Two concrete consequences, on every authorized route rather than any one of them: - A token minted from the local network and then replayed remotely is admitted here and refused upstream. The check at login does not survive the token. - A child account whose schedule allows 16:00–18:00 is admitted at midnight, for as long as its token lives. `AccessSchedules` is one of the 31 tables this project binds, and M7 wrote the user-management routes that maintain it, so the rows exist and are served — nothing reads them when a request arrives. **Read off upstream's source rather than measured.** Confirming it needs a restricted account on the reference, which is a write to the shared fixture; #111 established the pattern for that — stand up a private container from the pinned image and make the accounts there. ## Where to start `crates/jellyfin-http/src/auth.rs` is where a request becomes a caller, and it is the one place that can enforce this without 96 call sites remembering to. Upstream: `Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs`, and the `NetworkManager` call it uses to decide what "local" means — which this port models thinly, so what counts as the local network is its own question worth settling from `IsInLocalNetwork` rather than assumed to be loopback. Note the shape #111 chose for by-id reads and prefer it here: make the unchecked path impossible to express rather than adding a guard each route must remember. ## How to know it is done A restricted account's token, replayed from outside the local network and outside its schedule, is refused with the same status and body upstream gives — measured on a private pair, and carried by a scenario so a replay holds it. ### House rules Tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin**; generated artifacts are regenerated, never edited; comments say what the code does, not how it got there. The shared reference on `:8096` is read-only — a login is a write, so anything needing an account wants its own container from the pinned image over a `sqlite3 .backup` copy of `reference/var/config/data/jellyfin.db`. Check `ss -lntp` for a free port before binding; stale servers from earlier work are still listening.
Claude 2026-08-14 06:47:47 +02:00
Author
Collaborator

Folding this into #131 rather than tracking it separately — it came out of that work, it is the same module, and the fix belongs in the same branch. Reopening only if it turns out to be larger than that branch should carry.

Folding this into #131 rather than tracking it separately — it came out of that work, it is the same module, and the fix belongs in the same branch. Reopening only if it turns out to be larger than that branch should carry.
Claude reopened this issue 2026-08-14 07:47:22 +02:00
Author
Collaborator

Reopening. It was folded into #131 on the reasoning that it was the same module; it is not — it changes the answer of every authorized route, and #131's implementer made the case with measurements rather than an opinion.

Measured, on a private pair

A token issued while inside an access schedule, with the window then moved away:

with that token upstream ours
GET /Users/Me 403, empty body 200
GET /Items?limit=1 403 200
GET /Sessions 403 200
GET /System/Info 200 200

/System/Info agreeing is not luck: it is [Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)] (SystemController.cs:68), one of exactly two actions upstream exempts from the parental check. The other is UserController.cs:128.

Why it is not a small change

  • It changes the answer of every authorized route — 253 registrations.
  • It needs a policy notion this port does not have. Authenticated is uniform today; the two exempt actions have to skip the schedule check, which means the type has to carry which policy it was reached under.
  • The 401/403 split is new behaviour: authentication succeeded and authorization failed, which nothing here models yet.
  • It costs two to three extra reads per authenticated request (by_id, permissions, access_schedules) where upstream reads a cached user. #111 paid +0.07 ms for three primary-key lookups on a by-id read and argued the cost; this wants the same argument with its own number.
  • It is not replayable until the fixture has a restricted account — every measurement above is from a throwaway pair, because creating an account on the shared reference is a write.

What is already done, and not part of this

The login door is closed in #131's branch: upstream refuses a login outside the schedule with 403 (UserManager.cs:598) where we answered 200, and crates/jellyfin-app/src/session/auth/mod.rs already refuses IsDisabled and remote access in that same place. That was the self-contained half.

jellyfin_core::config::is_in_local_network already exists and is what auth.rs and the login path use, so what "local network" means is settled and does not need re-deciding here.

The order to do it in

  1. DefaultAuthorizationHandler in Authenticated — remote access, the administrator short-circuit, the parental schedule, and the two IgnoreParentalControl actions modelled. Upstream: Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs.
  2. A restricted-account fixture, so 1 is replayable rather than measured once. The scenario format already carries account (#111 added it) and xtask reference provision already creates a restricted user; what is missing is one whose schedule and remote-access permission are set.
  3. The bench number for the extra reads.

Prefer the shape #111 chose: make the unchecked path impossible to express rather than adding a guard each route must remember.

**Reopening.** It was folded into #131 on the reasoning that it was the same module; it is not — it changes the answer of every authorized route, and #131's implementer made the case with measurements rather than an opinion. ## Measured, on a private pair A token issued while inside an access schedule, with the window then moved away: | with that token | upstream | ours | |---|---|---| | `GET /Users/Me` | **403**, empty body | 200 | | `GET /Items?limit=1` | **403** | 200 | | `GET /Sessions` | **403** | 200 | | `GET /System/Info` | 200 | 200 | `/System/Info` agreeing is not luck: it is `[Authorize(Policy = Policies.FirstTimeSetupOrIgnoreParentalControl)]` (`SystemController.cs:68`), one of exactly **two** actions upstream exempts from the parental check. The other is `UserController.cs:128`. ## Why it is not a small change - It changes the answer of **every authorized route** — 253 registrations. - It needs a **policy notion this port does not have**. `Authenticated` is uniform today; the two exempt actions have to skip the schedule check, which means the type has to carry which policy it was reached under. - The **401/403 split is new behaviour**: authentication succeeded and authorization failed, which nothing here models yet. - It costs **two to three extra reads per authenticated request** (`by_id`, `permissions`, `access_schedules`) where upstream reads a cached user. #111 paid +0.07 ms for three primary-key lookups on a by-id read and argued the cost; this wants the same argument with its own number. - It is **not replayable** until the fixture has a restricted account — every measurement above is from a throwaway pair, because creating an account on the shared reference is a write. ## What is already done, and not part of this The **login door** is closed in #131's branch: upstream refuses a login outside the schedule with 403 (`UserManager.cs:598`) where we answered 200, and `crates/jellyfin-app/src/session/auth/mod.rs` already refuses `IsDisabled` and remote access in that same place. That was the self-contained half. `jellyfin_core::config::is_in_local_network` already exists and is what `auth.rs` and the login path use, so what "local network" means is settled and does not need re-deciding here. ## The order to do it in 1. `DefaultAuthorizationHandler` in `Authenticated` — remote access, the administrator short-circuit, the parental schedule, and the two `IgnoreParentalControl` actions modelled. Upstream: `Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs`. 2. A **restricted-account fixture**, so 1 is replayable rather than measured once. The scenario format already carries `account` (#111 added it) and `xtask reference provision` already creates a `restricted` user; what is missing is one whose schedule and remote-access permission are set. 3. The bench number for the extra reads. Prefer the shape #111 chose: make the unchecked path impossible to express rather than adding a guard each route must remember.
Author
Collaborator

Closed by c9137c8 — the same commit as #142, which is what the batch note said
would happen: apart, that extractor gets written twice.

Both checks now run where a request becomes a caller, in
admitted() — upstream's DefaultAuthorizationHandler, in its own order: an
api key is unrestricted, a caller with no account decides nothing, the
remote-access check, the administrator short-circuit, then the parental
schedule. jellyfin_app::session::auth::admitted_at is reused for the schedule,
and jellyfin_core::config::is_in_local_network for what "local" means, as this
issue said it should be.

The policy set is not uniform, which is why each is a type. From upstream's
registrations: RequiresElevation is a role claim alone and runs neither check;
the FirstTimeSetup* policies run remote access and skip the schedule;
IgnoreParentalControl and FirstTimeSetupOrIgnoreParentalControl are the two
actions this issue counted as exempt from the parental check, and they are
GET /Users/{userId} and GET /System/Info. A single flag could not have said
that.

The cost this issue asked to be argued. Permissions are read once as the
request becomes a caller and carried on Authenticated, so administers() costs
no query where it used to cost one — the default policy's net addition is one
read of AccessSchedules, for a non-administrator only, plus one primary-key
read of the account added later by #144. Timed over a thousand sequential
/Users/Me requests: 0.464 ms median against 0.470 ms without the account read,
which is below the floor of that measurement. Recorded in D32, with the note
that it wants retaking on a library whose tables are not small.

The restricted-account fixture this issue asked for, item 2 of its own
ordering, is not done: the checks are held by route tests in
crates/jellyfin-http/tests/authorization.rs, twelve of them, which build the
account and the schedule they need in memory. A replayable fixture would want an
account whose schedule and remote-access permission are set, and that is a write
to the shared reference — worth its own issue if a scenario ever needs it.

Closed by `c9137c8` — the same commit as #142, which is what the batch note said would happen: apart, that extractor gets written twice. Both checks now run where a request becomes a caller, in `admitted()` — upstream's `DefaultAuthorizationHandler`, in its own order: an api key is unrestricted, a caller with no account decides nothing, the remote-access check, the administrator short-circuit, then the parental schedule. `jellyfin_app::session::auth::admitted_at` is reused for the schedule, and `jellyfin_core::config::is_in_local_network` for what "local" means, as this issue said it should be. **The policy set is not uniform, which is why each is a type.** From upstream's registrations: `RequiresElevation` is a role claim alone and runs neither check; the `FirstTimeSetup*` policies run remote access and skip the schedule; `IgnoreParentalControl` and `FirstTimeSetupOrIgnoreParentalControl` are the two actions this issue counted as exempt from the parental check, and they are `GET /Users/{userId}` and `GET /System/Info`. A single flag could not have said that. **The cost this issue asked to be argued.** Permissions are read once as the request becomes a caller and carried on `Authenticated`, so `administers()` costs no query where it used to cost one — the default policy's net addition is one read of `AccessSchedules`, for a non-administrator only, plus one primary-key read of the account added later by #144. Timed over a thousand sequential `/Users/Me` requests: 0.464 ms median against 0.470 ms without the account read, which is below the floor of that measurement. Recorded in D32, with the note that it wants retaking on a library whose tables are not small. **The restricted-account fixture this issue asked for**, item 2 of its own ordering, is not done: the checks are held by route tests in `crates/jellyfin-http/tests/authorization.rs`, twelve of them, which build the account and the schedule they need in memory. A replayable fixture would want an account whose schedule and remote-access permission are set, and that is a write to the shared reference — worth its own issue if a scenario ever needs it.
Sign in to join this conversation.
No description provided.