feat: requester role with admin approval (stacks on #2662) - #2670
Conversation
| 'common.both': 'Both', | ||
| 'common.cancel': 'Cancel', | ||
| } | ||
| return strings[key] ?? (typeof fallback === 'string' ? fallback : key) |
| 'users.roleRequester': 'Requester', | ||
| 'users.fieldRole': 'Role', | ||
| } | ||
| const s = strings[key] ?? key |
| 'users.fieldRole': 'Role', | ||
| } | ||
| const s = strings[key] ?? key | ||
| return s.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(vars[name] ?? '')) |
| 'requests.admin.rootFolder': 'Root folder', | ||
| 'requests.admin.monitorMode': 'Monitor', | ||
| } | ||
| const s = strings[key] ?? key |
| 'requests.admin.monitorMode': 'Monitor', | ||
| } | ||
| const s = strings[key] ?? key | ||
| return s.replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(vars[name] ?? '')) |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…ates and covers Review of #2670 found that in local-only mode a requester's cookie request from a LAN address went through ModeGrantsAdmin and was served as the admin, so the requester could read the API key from /auth/config and keep it after the mode was switched back. Disabled mode did the same for every requester. Middleware now lets a valid session (cookie or proxy) whose role is requester through as that requester before the mode grant runs, so the allow list holds in every auth mode. OPDSAuth refuses a requester's session before its disabled and local-only bypasses in the same way. What the mode grant does for admin and user sessions is unchanged. GET /auth/config also stops trusting the context role alone: when the request names a user, the key is returned only if that user's stored role is admin. The install itself (API key, an anonymous local client) still gets it. The guard's buckets become named limits. POST /requests now spends the provider bucket (its lookup reaches the provider), and marks the request charged so a handler that spends the bucket itself does not charge twice; AllowRequester is that handler side. The image proxy spends a separate, larger bucket (240, then four a second), because one library page loads a cover per book. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…new approval claims Findings from the security review of #2670. Create spends the provider bucket for a requester before the lookup, unless the guard already charged the request, so creates with ids the provider does not know (which store nothing and never fill the cap) are limited too: the 21st rapid create answers 429. The pending cap was counted, then a provider round trip, then an insert, so 24 concurrent creates reached 15 pending against a cap of 3. The count is now inside the INSERT (and the Reopen UPDATE) as a WHERE predicate, one statement under SQLite's single writer, and zero rows affected maps to the existing 429. The earlier count stays as a fast refusal before the provider call. A requestCreated for the same owner, kind and foreign id is sent at most once an hour (in memory, at most 4096 keys), so create and withdraw in a loop is one webhook. Approval claims carry a random token and are renewed every third of the TTL while the add runs; renew, complete and release match the token. The add cores already return once the author or book row exists and run the catalogue sync in the background, so the remaining synchronous work is provider lookups and addBookCore's row poll; renewal covers those however long they take, and a second approval can no longer retake the claim and release it under the first. Migration 089 gains claim_token, and claimed_at moves to milliseconds; 089 has not shipped. Request and approval bodies now reject any key that is not exactly a field name, since encoding/json folds case and accepted "Kind" beside "kind". Webhook text. The requestCreated sanitiser only replaced @, so <!channel>, <!here>, <@u123> and [text](https://evil.example) in a provider title or a username reached the admin's chat channel as live markup. notifier.SafeText now does the whole job in one place: CleanText (control and invisible characters stripped, whitespace collapsed, capped), then @, <, >, [ and ] replaced with fullwidth lookalikes, which read the same and mean nothing to Discord, Slack, Matrix or markdown. It lives in the notifier package so any event carrying outside text can use it. requestCreated uses it for title, author and username; the API's cleanRequestText delegates to CleanText. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…epoch cannot be read Second review of #2670: when the stored role lookup returned empty, or the session epoch lookup failed, a valid requester cookie fell through to ModeGrantsAdmin in local-only and disabled mode and was served as the admin during a database error. When the mode would grant admin but the request carries a correctly signed session whose role or epoch could not be read, Middleware now serves it as that session's user with no role. RestrictRequester holds an empty role with a user id to the allow list and RequireAdmin refuses it, so a database error fails closed. A revoked cookie (epoch read and different) and a request with no cookie are unchanged. New tests in internal/auth cover both lookup failures in local-only mode and requester precedence over the mode grant in local-only and disabled mode, alongside a role user session keeping the admin grant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
… requester, unlink URLs Second review of #2670. The claim renewer was stopped inline after runApproval, so a panicking add core (recovered by chi's Recoverer) left the renewer refreshing claimed_at forever and the request in approving, counted against the requester's cap, until restart. Approve now stops the renewer in a defer and, on a panic, releases the claim with its token and re-raises the panic so Recoverer still logs it. A test with a panicking adder checks the request is pending again, no renewer is left running, and a second approval goes through. Per item suppression let a requester cycle through different ids and send one requestCreated per create. On top of it, each requester now triggers at most 10 of those webhooks an hour (in memory, bounded like the item map); past that the webhook is skipped and one warning is logged per hour. Requests are still stored and shown. notifier.SafeText also breaks the "://" of a bare URL with fullwidth solidi, since Discord, Slack, Telegram and Matrix autolink them. Other formatting (backticks, underscores) is left alone. A new repository test puts a request in approving and checks a create and a reopen at the cap are refused; it fails when the cap predicate counts only 'pending', a mutation the earlier tests missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
There was a problem hiding this comment.
Reviewed the requester role implementation. The two security-review passes have already addressed the main concerns. Three observations on what remains:
internal/api/opds_auth.go — limiter reset before role check
In the basic-auth path (around the limiter.Reset(ip) block), the per-IP login rate-limiter is reset before opdsRoleAllowed refuses a requester:
if limiter != nil {
limiter.Reset(ip) // fires for a correct-password requester
}
if !opdsRoleAllowed(w, u.Role) { // refused here
return
}An OPDS brute-force originating from the same IP as a requester who knows their own password (or whose client retries automatically) will get the limiter counter reset on each of their successful auths, nullifying it for other users on that IP. Swapping the two blocks — refuse the role first, reset only if the user is allowed — closes it without touching normal paths. Low severity for a self-hosted install, but it's a one-line reorder.
internal/db/migrations/089_requests.sql — migration number
The PR body notes #2633 and #2607 both target 086. If either brings a follow-up at 087 or 088 before this merges, the numbering has a gap (migrations are applied in filename order and a gap is harmless, but it's easy to verify: ls internal/db/migrations | tail -5 before tagging).
In-memory notification dedup resets on restart
recentKeys (per-item webhook window) and ownerBudget (per-requester hourly cap) are process-local. After a restart a requester can immediately re-trigger requestCreated webhooks up to the burst limit. The code comments say "in memory"; the docs (docs/multi-user.md) don't surface it. Worth one sentence in the docs so operators aren't surprised after a container restart.
Everything else looks correct. The allow-list matcher's path-canonicality checks (raw path rejection, path.Clean, percent/backslash/control) are comprehensive. The claim-renewal token prevents the concurrent-approval race (TestRequestsApprove_SlowCoreKeepsClaim). Payload revalidation before the add core is tight. restrictedRole's fail-closed handling of empty roles (DB-error sessions held to the allow list) is right. decodeStrict with the reflection-based key check correctly covers the case-folding gap that DisallowUnknownFields leaves. The opdsSessionIsRequester early-exit before ModeDisabled is the correct placement.
— 🤖 Bindery triage bot (automated). Reply to correct me; a human will see it.
Role names become constants in internal/auth (RoleAdmin, RoleUser, RoleRequester) with one ValidRole check. The six places that validate a role call it: GetOrCreateByOIDC, SetRole and SetRoleUnguarded in internal/db, normalizeOIDCRole in internal/config, and WithOIDCDefaultRole and resolveProvisionRole in the OIDC handler. BINDERY_OIDC_DEFAULT_ROLE=requester is now a supported value. The user management API creates a requester when asked. The last admin guard in SetRole checked role == "user", so demoting the last admin to requester would have gone through. It now refuses any role that is not admin. OIDC group sync set "user" for every login outside the admin group, which would turn a requester into a full user at each sign in. The rule is now: in the admin group gives admin; outside it, an admin gets the configured default role (user when that default is admin, so leaving the group always demotes); anyone else keeps their role. Nothing routes on the new role yet. The API guard follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
auth.RestrictRequester sits in useAPIAuth right after auth.Middleware, so it covers both the /api/v1 tree and the Arr compatible /api tree. For a requester it matches the method and path against RequesterAllowList with a chi router built once at startup, and answers 403 for anything else. The router in main() cannot be enumerated in a test, so the list is an allow list: a route added later is closed to requesters until someone lists it. The list is health, the session routes, the three metadata searches the Add dialog uses, the image proxy, and the requester's own /requests routes (added with the requests API). Admin and user requests pass untouched, as do requests the auth mode admits as the install. A signed in user whose role cannot be read is restricted, so a lookup failure fails closed. Matching refuses any path that could route differently from its plain reading: a set RawPath (encoded slash, escaped letter), anything path.Clean would change (dot segments, doubled or trailing slash), and a leftover percent sign, backslash or control character. HEAD matches as GET. Method override headers are never read, and a chi routing method that disagrees with r.Method is refused. The three search routes go through a per user token bucket (20 burst, one every 3 seconds) held in a map bounded at 1024 users with idle eviction, because each call spends metadata provider quota. OPDS serves book files, so OPDSAuth now refuses requesters on both the cookie and the Basic credential paths. The BINDERY_URL_BASE mount moves out of main() into mountUnderURLBase, unchanged, so the route tests can put the real prefix handling in front of the auth stack. Tests: 81 denied routes, each in HEAD, trailing slash, doubled slash, dot segment, mixed case, encoded slash and escaped letter spellings; every allow list entry passes; FuzzRequesterAllowList over raw request lines; chi.Walk over every register* helper; both trees with and without a URL base through the real provider; OPDS by cookie and by Basic; demotion applies on the next request; the limiter bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Migration 089 adds the requests table (one row per owner, kind and
foreign id; owner cascades on user delete; decided_by and the result ids
go NULL when their rows go) and notifications.on_request_created, off
for every existing notification.
POST /requests takes exactly kind, foreignId and mediaType, from a body
capped at 4 KiB, and refuses any other field. The server looks the item
up at the metadata provider and stores the title and author it reports,
cleaned and capped, plus a payload it builds itself. It answers 409 with
a sentence when the item is already in the library or this user already
asked, and 429 past requests.max_pending_per_user (default 25, a new
setting). GET /requests lists the caller's own requests with fulfilment
derived at list time: a join to the book's status, and one grouped query
per page for an author's import progress. DELETE /requests/{id}
withdraws the caller's own pending request. Every owner query filters on
owner_user_id whether tenancy is on or not.
GET /requests/library is the requester's read only browse: a projection
selected column by column (id, title, author, primary series, cover,
status, formats present) and copied field by field into its own type.
It never reads a file path. A reflection test pins the response type to
that field list.
The admin routes sit behind RequireAdmin: the queue, a pending count
for the nav badge, approve and decline. Approve claims the row with a
compare and swap on status, so of two admins approving at once exactly
one add runs and the other gets 409; a claim older than five minutes is
treated as abandoned. It revalidates the stored payload against the row,
rechecks the library, and runs addBookCore or createAuthorCore through a
narrow requestAdder interface that *AuthorHandler satisfies, in a
context carrying the requester's id so the new rows are theirs. The
admin supplies the profiles, root folder, monitor options, format and
search on add. Any failure releases the claim.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
The base branch now refuses SearchOnAdd together with SkipCatalogueSync in createAuthorCore, because the search only runs inside that sync. Approval never skips the sync; the params say so explicitly, a test runs an author approval with search on add through the real core and checks the params the fake records, and the sentinel, should a later change reach it, maps to a sentence an admin can act on and releases the claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
The notifier recipe, followed as it stands: the EventRequestCreated
constant, a normalizeEventPayload case ("Book Requested" or "Author
Requested", the title, the author and who asked), a matchesEvent case,
Notification.OnRequestCreated, the on_request_created column in the
four notification statements, and a Request toggle and badge in the
notifications settings. The column from migration 089 defaults to 0, so
nothing fires until an admin turns it on.
The requests handler sends the event after a request is stored, without
holding up the requester's response. The payload text comes from a
publicly editable provider and a self chosen username and lands in a
chat channel, so title, author and username have control and invisible
characters stripped, are capped, and have every at sign replaced with
the fullwidth at sign so they cannot mention a channel (security review
item S3). A refused request sends nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9
Signed-off-by: vavallee <vavallee@protonmail.com>
AuthContext exposes role and isRequester, plus useIsRequester for components that also render outside the provider. The nav is picked by role. A requester gets three pages and nothing else: Library (the read only projection, with search and no links into book pages), Request (the metadata search the Add dialog runs) and My requests (status, the decline reason, author import progress, and withdraw for a pending request). Any other path sends them to Library, and the shell stops calling /system/status, the library search and the settings link, all closed to them server side. For a requester the Add Author and Add Book confirm steps become one request step: a format choice and a Request button that posts kind, foreignId and mediaType. The admin options, and the profile, root folder, setting and indexer lookups behind them, are never loaded. Admins get Requests in the nav with a pending count, read once and again after each decision, never polled. The queue filters by status; Approve opens an inline form (profile, root folder, monitor and format for an author, format and search on add for a book) prefilled from the instance defaults, and Decline takes an optional reason. The Users page role column becomes an admin, user, requester select, and the create form offers requester too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
The router is built inline in main(), so only the booted binary proves the allow list sits on every tree. The smoke suite now creates an admin through first run setup and a requester through the admin API, signs the requester in with a cookie and a CSRF token, and checks a grab, an author read, a file download, a settings read, the Arr compatible queue, a doubled slash and an encoded slash all answer 403, OPDS by Basic credentials answers 403, and the requester's own routes answer 200. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…p rule docs/multi-user.md gains a Requester section: what the role can and cannot do, how approval works, that it needs the auth mode set to enabled or proxy, and what tenancy on and off mean for the library a requester sees. The capability matrix gets a requester column. docs/auth-oidc.md lists requester as a default role and states the new group sync rule. docs/auth-proxy.md says there is no header to role mapping and an admin sets the role after the account exists. docs/API.md documents the /requests routes, the approval body, the requester's route surface and the requestCreated webhook fields. README Features and a changelog fragment round it off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…ates and covers Review of #2670 found that in local-only mode a requester's cookie request from a LAN address went through ModeGrantsAdmin and was served as the admin, so the requester could read the API key from /auth/config and keep it after the mode was switched back. Disabled mode did the same for every requester. Middleware now lets a valid session (cookie or proxy) whose role is requester through as that requester before the mode grant runs, so the allow list holds in every auth mode. OPDSAuth refuses a requester's session before its disabled and local-only bypasses in the same way. What the mode grant does for admin and user sessions is unchanged. GET /auth/config also stops trusting the context role alone: when the request names a user, the key is returned only if that user's stored role is admin. The install itself (API key, an anonymous local client) still gets it. The guard's buckets become named limits. POST /requests now spends the provider bucket (its lookup reaches the provider), and marks the request charged so a handler that spends the bucket itself does not charge twice; AllowRequester is that handler side. The image proxy spends a separate, larger bucket (240, then four a second), because one library page loads a cover per book. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…new approval claims Findings from the security review of #2670. Create spends the provider bucket for a requester before the lookup, unless the guard already charged the request, so creates with ids the provider does not know (which store nothing and never fill the cap) are limited too: the 21st rapid create answers 429. The pending cap was counted, then a provider round trip, then an insert, so 24 concurrent creates reached 15 pending against a cap of 3. The count is now inside the INSERT (and the Reopen UPDATE) as a WHERE predicate, one statement under SQLite's single writer, and zero rows affected maps to the existing 429. The earlier count stays as a fast refusal before the provider call. A requestCreated for the same owner, kind and foreign id is sent at most once an hour (in memory, at most 4096 keys), so create and withdraw in a loop is one webhook. Approval claims carry a random token and are renewed every third of the TTL while the add runs; renew, complete and release match the token. The add cores already return once the author or book row exists and run the catalogue sync in the background, so the remaining synchronous work is provider lookups and addBookCore's row poll; renewal covers those however long they take, and a second approval can no longer retake the claim and release it under the first. Migration 089 gains claim_token, and claimed_at moves to milliseconds; 089 has not shipped. Request and approval bodies now reject any key that is not exactly a field name, since encoding/json folds case and accepted "Kind" beside "kind". Webhook text. The requestCreated sanitiser only replaced @, so <!channel>, <!here>, <@u123> and [text](https://evil.example) in a provider title or a username reached the admin's chat channel as live markup. notifier.SafeText now does the whole job in one place: CleanText (control and invisible characters stripped, whitespace collapsed, capped), then @, <, >, [ and ] replaced with fullwidth lookalikes, which read the same and mean nothing to Discord, Slack, Matrix or markdown. It lives in the notifier package so any event carrying outside text can use it. requestCreated uses it for title, author and username; the API's cleanRequestText delegates to CleanText. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
docs/multi-user.md no longer says the role needs enabled or proxy mode to restrict anything: a requester's session is never elevated by the mode, though in disabled and local-only mode a signed out caller is still the admin, so the advice to use enabled or proxy stays. It also mentions that creates share the search allowance, covers have their own, and repeated alerts for one request are suppressed for an hour. docs/API.md covers the create rate limit and the atomic cap under 429, the image limit, claim renewal, and the wider webhook sanitising. The changelog fragment follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…veryone else 0453d9b made GET /auth/config return the API key only when the named user's stored role was admin. That also hid it from a role user session that local-only mode stamps admin, a change for existing installs, and that session can still regenerate the key, so someone who cannot see it is likely to rotate it and break every integration using the old one. The rule is now narrower: a request naming a user whose stored role is requester never gets the key, in any mode. Admin and user behave exactly as on main. The route test checks a role user session in local-only mode still receives the key; the unit test's "user stamped admin" case flips back to receiving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…ation The required lint check (golangci-lint v2.11.4 over the whole module) fails with G202 on the request list query and the library projection query. Both concatenate only package constants and fixed clauses and bind every value, so they follow the internal/db convention: a #nosec G202 comment naming why no input reaches the SQL text. golangci-lint run ./... now reports 0 issues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
…epoch cannot be read Second review of #2670: when the stored role lookup returned empty, or the session epoch lookup failed, a valid requester cookie fell through to ModeGrantsAdmin in local-only and disabled mode and was served as the admin during a database error. When the mode would grant admin but the request carries a correctly signed session whose role or epoch could not be read, Middleware now serves it as that session's user with no role. RestrictRequester holds an empty role with a user id to the allow list and RequireAdmin refuses it, so a database error fails closed. A revoked cookie (epoch read and different) and a request with no cookie are unchanged. New tests in internal/auth cover both lookup failures in local-only mode and requester precedence over the mode grant in local-only and disabled mode, alongside a role user session keeping the admin grant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
… requester, unlink URLs Second review of #2670. The claim renewer was stopped inline after runApproval, so a panicking add core (recovered by chi's Recoverer) left the renewer refreshing claimed_at forever and the request in approving, counted against the requester's cap, until restart. Approve now stops the renewer in a defer and, on a panic, releases the claim with its token and re-raises the panic so Recoverer still logs it. A test with a panicking adder checks the request is pending again, no renewer is left running, and a second approval goes through. Per item suppression let a requester cycle through different ids and send one requestCreated per create. On top of it, each requester now triggers at most 10 of those webhooks an hour (in memory, bounded like the item map); past that the webhook is skipped and one warning is logged per hour. Requests are still stored and shown. notifier.SafeText also breaks the "://" of a bare URL with fullwidth solidi, since Discord, Slack, Telegram and Matrix autolink them. Other formatting (backticks, underscores) is left alone. A new repository test puts a request in approving and checks a create and a reopen at the cap are refused; it fails when the cap predicate counts only 'pending', a mutation the earlier tests missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
docs/multi-user.md and docs/API.md say a requester triggers at most 10 Request webhooks an hour and that URLs in webhook text no longer autolink; the changelog fragment follows, and says /auth/config never returns the API key to a requester rather than "only to a stored admin". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
Rebasing onto main brought in the adoption routes (#2547) and the download client diagnose route (#2661). The route walk stub gains their methods so chi.Walk still enumerates every register* helper, and the denied table names /library/unmatched, its summary, adopt and ignore routes, and /downloadclient/{id}/diagnose, which the allow list refuses like everything else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9 Signed-off-by: vavallee <vavallee@protonmail.com>
c3d27b3 to
3760cc9
Compare
Summary
Adds a
requesterrole: an account that can browse the library read only, search for a book or an author and ask for it, but cannot grab, download, delete, use OPDS or see settings. Admins get a Requests queue; approving runs the ordinary add with the requester as the owner. This is PR 4 of the four feature plan.Rebased onto
mainnow that #2662 merged (as9b312bf8). Approval calls itsaddBookCoreandcreateAuthorCorerather than adding anything itself. See "Rebased onto main" below for how the conflicts with release discovery (#2663) and library adoption (#2671) were resolved.Suggested review order
One commit per stage, each builds and passes its tests:
feat(auth): add the requester role and keep it through OIDC group sync: role constants,ValidRole, the six validators, the last admin guard, the OIDC group rule.feat(auth): hold requesters to an allow list on every API route:auth.RestrictRequester, the limiter, OPDS,mountUnderURLBase. The security core; please read this one closely.feat(api): let requesters ask for books and authors, and admins approve: migration 089,RequestRepo, the projection,/requests.test(api): keep the catalogue sync when an approval searches on add: follows the base branch's newerrCreateAuthorSearchNeedsSync.feat(notifier): send requestCreated when a requester asks for somethingfeat(web): give requesters their own shell and admins a request queuetest(smoke): sign in as a requester against the real routerdocs: document the requester role, request approval and the OIDC group rulefix(auth): never let the auth mode elevate a requester, and limit creates and covers(security review items 1, 4, 6)fix(api): rate limit request creates, make the pending cap atomic, renew approval claims(items 1, 2, 3, 5, 7, 8)docs: requester restrictions hold in every auth mode, and the new limitsfix(api): withhold the API key from requesters only, as on main for everyone elsefix(db): annotate the two request queries gosec flags as SQL concatenationfix(auth): do not let the mode grant replace a session whose role or epoch cannot be read(second review item 2)fix(api): release the claim when an approval panics, cap webhooks per requester, unlink URLs(second review items 1, 3, 4, 5)docs: the per requester webhook cap and link neutralisingtest(api): teach the requester route tests about adoption and diagnose(after the rebase)Where the guard sits and what it covers
useAPIAuth(cmd/bindery/sensitive_routes.go) is the one function both API trees call:r.Route("/api", ...)(the Arr compatible queue) andr.Route("/api/v1", ...).auth.RestrictRequesteris installed in it directly afterauth.Middleware, so both trees get it, and so does any route either tree gains later.BINDERY_URL_BASEis applied byhttp.StripPrefixaround the whole router, which rewritesr.URL.Pathandr.URL.RawPathbefore dispatch, so the guard matches the unprefixed path. I lifted that block out ofmain()unchanged intomountUnderURLBaseso the route test runs the same prefix handling; the test covers both trees with and without/bindery.Every other authenticated or public surface:
/api/v1/*/api(Arr queue)useAPIAuth/opds/*(feeds, files, images)OPDSAuthon both the cookie and the Basic path/metrics/__bindery_base.js, SPA static files/api/v1/imagesRoles the guard leaves alone:
adminanduser. A request with no user id and no role only reaches the guard throughAllowUnauthPath(setup, login and so on), whichMiddlewarealready constrained. A signed in user whose role lookup returned empty, or an unknown role, is restricted: fail closed.The auth mode never elevates a requester:
auth.Middlewarepasses a valid requester session through as the requester before the disabled and local-only grants, andOPDSAuthrefuses it before its bypasses. The API key still acts as the admin whatever cookie rides along. In disabled and local-only mode a caller who signs out is still admin, so the docs recommendenabledorproxyfor requester accounts.Wiring of the add cores
The requests handler depends on a two method interface,
requestAdder { addBookCore; createAuthorCore }, which*AuthorHandlersatisfies. I preferred it to a*AuthorHandlerfield because the requests handler then cannot reach the author handler's repositories, provider or searcher, so the only way an approval adds anything is the code path the Add dialog runs; and the approval's own logic (claim, revalidation, owner context, release on failure, concurrency) is tested against a fake without a metadata provider. The real core is exercised too, inTestRequestsApprove_CreatesRowsOwnedByRequesterandTestRequestsApprove_AuthorRequestUsesAdminChoices.addBookParamshas no profile or root folder fields, so a book approval offers format and search on add (what the Add Book dialog offers); an author approval offers the full set. Approval never setsSkipCatalogueSync, so it cannot hit the base branch's newerrCreateAuthorSearchNeedsSync; a test proves it with the real core, and the sentinel maps to an admin facing sentence if a later change reaches it.Security
S1, projection.
GET /requests/libraryselects id, title, author name, primary series and position, image url, status and twoEXISTSflags column by column (internal/db/requests_library.go), so no file path is ever read, and copies them field by field intorequesterLibraryBook.TestRequesterLibraryBook_FieldAllowListwalks the type by reflection and fails on any field or JSON name not on the list, or on an embedded struct.TestRequestsLibrary_ProjectionCarriesNoPathsseeds files under a marker path and asserts the marker,filePath, provider ids,monitoredand owner appear nowhere in the body./author,/book,/seriesstay off the allow list.S2, server built payload.
POST /requestsdecodes withDisallowUnknownFieldsfrom a 4 KiBMaxBytesReaderand accepts exactlykind,foreignId,mediaType. The server callsGetBookorGetAuthor, stores the provider's cleaned title and author, and buildspayload_jsonitself. 409 with a sentence for already in library (global check, including the provider's canonical id) or already requested by this user, 429 past the cap. Approve takes the admin's choices (strict decode, 8 KiB), claims the row, revalidates the stored payload against the row (kind, foreign id, id shapes, media type), rechecks the library, and runs the core in a context carrying the requester's id and role.TestRequestsCreate_ServerBuildsPayloadsends title, rootFolderId, searchOnAdd, qualityProfileId, trailing objects and arrays: all 400.TestRequestsApprove_RevalidatesPayloadtampers with the stored payload five ways: all 422 with no add.S3, webhook text.
requestCreatedPayloadstrips control and format characters (bidi overrides, zero width) withcleanRequestText, caps title 300, author 200, username 64 runes, and replaces every@in all three with the fullwidth at sign.TestRequestsCreate_SendsSanitisedRequestCreateddrives a hostile provider title, author and username through a real create.S4, abuse limits. Per user token bucket (burst 20, one per 3 s) on
/search/author,/search/book,/book/lookupandPOST /requestsfor requesters only (the create handler also spends it if the guard did not), a separate larger bucket on/images, held in a map capped at 1024 users with idle eviction (swept at most once per 10 minutes, least recently used evicted when full).requests.max_pending_per_user, default 25, registry entry and validator (1 to 10000), enforced inside the insert so concurrent creates cannot pass it. Body caps as above.S5, OIDC group sync.
groupSyncRole: in the group gives admin; not in it and currently admin gives the configured default role (user when the default is admin, so leaving the group always demotes); otherwise the current role. Tests for all three plusBINDERY_OIDC_DEFAULT_ROLE=requesterat config and provisioning. The last admin guard now refuses a demotion to any non admin role; before, it only checkedrole == "user", soSetRole(lastAdmin, "requester")would have left no admin.S6, path matching. The matcher refuses before lookup: a set
RawPath(any escape that is not canonical, including%2Fand%31), any pathpath.Cleanwould change (dot segments, doubled or trailing slash), and a leftover%, backslash or control character. HEAD matches as GET. Onlyr.Methodis read; a chi routing method that differs from it is refused.TestRestrictRequester_DeniesEveryListedRoutecovers 81 denied method and path pairs, each in HEAD, trailing slash, doubled slash,./,x/../, upper case,%2F,%2fand escaped letter spellings. The same spellings of every allow list entry are refused too.TestRestrictRequester_IgnoresMethodOverridecovers three override headers.Plan items
defaultRequesterMatcherat package init; limiter bounded with idle eviction; author progress is one grouped query per page (fillAuthorProgress)auth.RoleAdmin/RoleUser/RoleRequester,auth.ValidRoleused by all six validators; literals replaced only in files this PR touchesstubMetaProvider,addBookBackCatalogueStub, the OIDCfakeIDPharness,vi.mock('../api/client')FuzzRequesterAllowListhttp.ReadRequestinto a router shaped like the real one; 60 s local run, 2.3M execs, no failureTestRequestRepo_ClaimIsExclusive(8 racers, one claim) andTestRequestsApprove_ConcurrentApprovalsAddOnce(two approvals, one add, one 409), both under-raceTestMigration089_OnPopulatedDatabase: existing webhook keepson_request_created = 0, users kept, table empty, kind CHECK holdsMigrations
After the rebase, main carries 085, 087 (discovery) and 088 (adoption). 086 is still claimed by the two open PRs #2633 and #2607. This PR keeps 089, which is free.
Security review fixes
An independent review against a live binary found no allow list bypass. It found the eight items below, fixed in
0453d9b1,5644ea10and72209077. The fail before evidence ran base compatible copies of the tests against274f7f2c, the previous head of this branch, then the same files against the fix.274f7f2c)POST /requestsnot rate limited; failed lookups never fill the capLimitProvider, andCreatespends the provider bucket itself before the lookup unless the guard already charged the request (a context flag, so no double charge)TestRequestsCreate_RateLimitedInHandler(22nd rapid create is 429),TestRestrictRequester_CreateAndImagesLimited,TestRequesterLimiter_AllowRequester404; third create through a 2 token guard200WHEREpredicate inside theINSERT ... SELECT(and the reopenUPDATE); zero rows affected is the existing 429TestRequestsCreate_PendingCapHoldsUnderConcurrency,TestRequestRepo_CreateCapIsAtomic(24 racers, cap 3,-race),TestRequestRepo_ReopenRespectsCappending 24 against a cap of 3requestCreatedfor one (owner, kind, foreign id) at most once an hour, in memory, at most 4096 keys (expired dropped first, then the oldest)TestRequestsNotify_RepeatWithinWindowSuppressed,TestRecentKeys_WindowAndBoundsent 5 requestCreated webhooksauth.Middlewarelets a valid session whose role is requester through as the requester before the mode grant, in every mode;OPDSAuthrefuses a requester session before its disabled and local-only bypasses;GET /auth/configwithholds the key when the named user's stored role is requester (narrowed in88ef4c0c; admin and user as on main)TestRequesterGuard_ModeNeverElevatesARequester(local-only and disabled: requester 403 on/queue, no key; role user in local-only still granted; anonymous local client still gets the key; API key still admin),TestRequesterGuard_OPDSModeNeverElevatesARequester,TestGetConfig_APIKeyNeverForRequestersGET /queue204;/auth/configreturned"apiKey":"k-requester-test"@notifier.SafeText(shared, in the notifier package):CleanText, then@ < > [ ]to fullwidth lookalikes. Covers<!channel>,<!here>,<@U123>,<@&role>,<link|label>, markdown links and imagesTestSafeText_NeutralisesChatMarkup(one case per vector),TestRequestsCreate_SendsSanitisedRequestCreated<!channel> [Free nitro](https://evil.example)passed through/imagesunlimited for requestersLimitImage, its own bucket: 240, then 4 a second, so a library page of 60 covers is unaffectedTestRestrictRequester_CreateAndImagesLimited200createAuthorCorealready runs the catalogue sync in a background job and returns once the author row exists; what stays synchronous is provider lookups andaddBookCore's up to 15 s row poll, so completing earlier gains nothing. Instead each claim carries a random token and is renewed every third of the TTL while the add runs; renew, complete and release match the token.claimed_atis now milliseconds and 089 gainsclaim_token(089 has not shipped)TestRequestsApprove_SlowCoreKeepsClaim(TTL 300 ms, add holds 1 s, second approve at 600 ms gets 409, one add),TestRequestRepo_ReleaseAndStaleClaim(a lost token cannot renew or release)500, 2 addsdecodeStrictreads the object's keys first and refuses any key not exactly a field nameTestRequests_RejectCaseFoldedKeyskindplusKindanswered201On item 7's before and after: with the claim forced stale by SQL (which bypasses renewal), the fixed code answers the first approval
409rather than a500for either one, because the tokens stop the approvals completing or releasing each other's claims. What actually prevents a takeover is renewal, whichTestRequestsApprove_SlowCoreKeepsClaimcovers.Items not taken: none.
API key narrowing, lint, and the second security review
API key narrowing (
88ef4c0c).GET /auth/confignow withholds the key only when the named user's stored role isrequester, in every mode. Admin and user behave exactly as onmain, including a roleusersession that local-only mode stamps admin: that session can regenerate the key, so hiding it would invite a rotation that breaks integrations.TestRequesterGuard_ModeNeverElevatesARequesternow also checks that such a user session still receives the key, andTestGetConfig_APIKeyNeverForRequesterscovers the matrix.Lint (
3a7de943). The requiredlintcheck (golangci-lint v2.11.4,golangci-lint run ./...with the repository.golangci.yml) failed with gosec G202 oninternal/db/requests.go(request list query) andinternal/db/requests_library.go(projection query). Reproduced locally with the same version, then fixed following theinternal/dbconvention: a// #nosec G202 -- reasoncomment naming why no input reaches the SQL text (constants and fixed clauses only, every value bound).golangci-lint run ./...reports 0 issues.The second review ran against
72209077and found no guard bypass. The fixes are inc50830f8,53789c3candc3d27b3a. Fail before evidence ran base compatible copies of the tests against3a7de943, then the same files against the fix.3a7de943)approvingdefer stopRenew()(idempotent); a deferredrecoverreleases the claim with its token and re-panics so Recoverer still logsTestRequestsApprove_PanicReleasesClaim(pending again, 0 renewers running, second approval 200; the panic still reaches the caller)approvingTestMiddleware_UnreadableSessionNotElevatedByMode(both failures, local-only),TestMiddleware_RequesterNotElevatedByMode(requester precedence ininternal/auth, local-only and disabled, user keeps the grant)GET /queue200requestCreatedwebhooks per requester per hour (in memory, bounded at 4096 owners); past that the webhook is skipped and one warning logged per hour. Requests are still storedTestRequestsNotify_PerOwnerCap,TestOwnerBudget_WindowAndBoundSafeTextreplaces://with:and two fullwidth solidi; backticks, underscores and similar are untouchedTestSafeText_BareURLsDoNotLinkhttps://evil.example/loginkeptapprovingfrom the cap predicate survived every testapproving: create and reopen at the cap are refusedTestRequestRepo_CapCountsRequestsBeingApproved'pending'only, the create was acceptedRebased onto main
#2662 merged as
9b312bf8, so the three add core commits this branch was stacked on are gone from it; the branch now sits onmainwithgit rebase --onto. Migration 089 needed no renumbering: main has 085, 087 (discovery) and 088 (adoption), and 086 is still claimed by two open PRs.Conflicts, and how each was resolved:
internal/notifier/notifier.go,internal/models/notification.go,internal/db/notifications.gobookAnnouncedconstant, case and column stay, andrequestCreatedsits beside them. Every notification statement now carrieson_book_announced, on_request_createdweb/src/pages/settings/NotificationsTab.tsx,web/src/api/notifications.ts, en.jsonaria-pressed, matching the New book toggleweb/src/App.tsxnavLabel(item)renders both badges: the adoption count on Import and the pending count on Requests.navItemsForkeeps the requester shell and appends Requests for adminsweb/src/api/client.tsadoptionApiandrequestsApicomposed inweb/src/App.test.tsxREADME.mddocs/API.mdeventTyperow listingbookAnnouncedandrequestCreated; the toggle paragraph namesonBookAnnouncedandonRequestCreatedand both migrationscmd/bindery/requester_routes_test.go/library/unmatched*and/downloadclient/{id}/diagnose(3760cc96)Discovery's
announceText(ininternal/api) and this PR'snotifier.SafeTextoverlap: both strip control characters and cap length, but onlySafeTextneutralises mentions, Slack escapes, markdown links and URL schemes. I leftannounceTextalone rather than change a merged feature's output here. RoutingbookAnnouncedthroughSafeTextis a small follow up worth doing: an OpenLibrary title reaching that webhook can still carry@everyone.Verification after the rebase:
go build ./...,go vet ./..., Windows and darwin builds,go test ./cmd/... ./internal/...(exit 0),golangci-lint run ./...(0 issues),-raceon auth, notifier, cmd/bindery, the request and notification tests in db, and the requester, requests, OIDC, OPDS, user management and add core tests in api, the smoke suite against a rebuilt binary,npx vitest run(92 files, 1054 tests),npx tsc -b,npm run build, and eslint (warnings only, all pre-existing).Fail before evidence
The branch's tests use
auth.RoleRequesterand repository calls the base refuses, so I ran base compatible copies (string literals, the requester role written with SQL) against11aefa67(the tip ofrefactor/add-book-core), then the same files against this branch.On the base:
On this branch the same three tests pass. The OIDC one is plan item T8. The last admin case fails on the base for a different reason (the role is invalid there); the guard bug itself is latent until requester is valid, which is why the branch's
TestSetRole_LastAdminCannotBecomeRequesterandTestUserMgmt_SetRole_DemoteLastAdminToRequesterexist. On the base a requester role row is read as a normal user, so every route answered.Performance
GET /requestsis two statements plus at most one groupedINquery per page for author progress.GET /requests/libraryis a count and one page query; series and format flags are correlated subqueries backed byidx_series_books_bookandidx_book_files_book_id.requestCreatedruns in a goroutine with a 30 s detached context, so a slow target does not hold up the requester.Follow ups (not in this PR)
The image cache has no total size cap. It predates this PR and affects role
useras much as requesters; the requester bucket only slows a requester filling it.In
disabledandlocal-onlymode, ausersession is still elevated to admin by the mode grant. This PR stops it only for requesters; changing it foruseris a broader behaviour change.Repo wide replacement of the remaining
"admin"and"user"literals with the constants (C2 says separate mechanical PR).A generic event set for notifications instead of one column per event (C7).
Deferred by the plan: auto approve per user, quotas beyond the pending cap, per user notification targeting, proxy header role mapping.
Profile and root folder choices for book approvals would need
addBookParamsto grow them.Checklist
docs/multi-user.md,docs/auth-oidc.md,docs/auth-proxy.md,docs/API.md, README Features)changelog.d/requester-role.mdTest plan
go build ./...,go vet ./...GOOS=windows go build ./...,GOOS=darwin go build ./...go test ./cmd/... ./internal/...(exit 0)-race:./internal/auth ./internal/config ./internal/notifier ./cmd/binderypass;./internal/apifor the requests, requester, OIDC, callback, user management, OPDS, settings and add core tests pass;./internal/dbfor the request repo, migration 089, notification round trip and role tests pass. The whole./internal/dbpackage under-racehit the 10 minute default timeout on the shared machine (a timeout, not a failure), likeinternal/apiin make test: internal/api race package hits the 30-minute timeout #2293go test -fuzz FuzzRequesterAllowList -fuzztime 60s ./internal/auth(2.3M execs, no failure)make smokeequivalent: binary built with the web assets,go test ./tests/smoke/...including the new requester casecd web && npx vitest run(86 files, 1018 tests),npm run build,npx tsc -b🤖 Generated with Claude Code
https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9