Skip to content

feat: requester role with admin approval (stacks on #2662) - #2670

Merged
vavallee merged 17 commits into
mainfrom
feature/requester-role
Sep 18, 2026
Merged

vavallee merged 17 commits into
mainfrom
feature/requester-role

Conversation

@vavallee

@vavallee vavallee commented Sep 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a requester role: 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 main now that #2662 merged (as 9b312bf8). Approval calls its addBookCore and createAuthorCore rather 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:

  1. 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.
  2. 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.
  3. feat(api): let requesters ask for books and authors, and admins approve: migration 089, RequestRepo, the projection, /requests.
  4. test(api): keep the catalogue sync when an approval searches on add: follows the base branch's new errCreateAuthorSearchNeedsSync.
  5. feat(notifier): send requestCreated when a requester asks for something
  6. feat(web): give requesters their own shell and admins a request queue
  7. test(smoke): sign in as a requester against the real router
  8. docs: document the requester role, request approval and the OIDC group rule
  9. fix(auth): never let the auth mode elevate a requester, and limit creates and covers (security review items 1, 4, 6)
  10. fix(api): rate limit request creates, make the pending cap atomic, renew approval claims (items 1, 2, 3, 5, 7, 8)
  11. docs: requester restrictions hold in every auth mode, and the new limits
  12. fix(api): withhold the API key from requesters only, as on main for everyone else
  13. fix(db): annotate the two request queries gosec flags as SQL concatenation
  14. fix(auth): do not let the mode grant replace a session whose role or epoch cannot be read (second review item 2)
  15. fix(api): release the claim when an approval panics, cap webhooks per requester, unlink URLs (second review items 1, 3, 4, 5)
  16. docs: the per requester webhook cap and link neutralising
  17. test(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) and r.Route("/api/v1", ...). auth.RestrictRequester is installed in it directly after auth.Middleware, so both trees get it, and so does any route either tree gains later.

BINDERY_URL_BASE is applied by http.StripPrefix around the whole router, which rewrites r.URL.Path and r.URL.RawPath before dispatch, so the guard matches the unprefixed path. I lifted that block out of main() unchanged into mountUnderURLBase so the route test runs the same prefix handling; the test covers both trees with and without /bindery.

Every other authenticated or public surface:

Surface Requester Why
/api/v1/* allow list only guard
/api (Arr queue) 403 guard, same useAPIAuth
/opds/* (feeds, files, images) 403 role check added to OPDSAuth on both the cookie and the Basic path
/metrics unchanged mounted outside auth for Prometheus; a requester gets nothing an anonymous client does not
/__bindery_base.js, SPA static files unchanged public build assets, no data
/api/v1/images allowed, rate limited covers for the projection; same SSRF guarded proxy every role uses, with its own per requester bucket
websocket or event stream none exist

Roles the guard leaves alone: admin and user. A request with no user id and no role only reaches the guard through AllowUnauthPath (setup, login and so on), which Middleware already 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.Middleware passes a valid requester session through as the requester before the disabled and local-only grants, and OPDSAuth refuses 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 recommend enabled or proxy for requester accounts.

Wiring of the add cores

The requests handler depends on a two method interface, requestAdder { addBookCore; createAuthorCore }, which *AuthorHandler satisfies. I preferred it to a *AuthorHandler field 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, in TestRequestsApprove_CreatesRowsOwnedByRequester and TestRequestsApprove_AuthorRequestUsesAdminChoices.

addBookParams has 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 sets SkipCatalogueSync, so it cannot hit the base branch's new errCreateAuthorSearchNeedsSync; 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/library selects id, title, author name, primary series and position, image url, status and two EXISTS flags column by column (internal/db/requests_library.go), so no file path is ever read, and copies them field by field into requesterLibraryBook. TestRequesterLibraryBook_FieldAllowList walks the type by reflection and fails on any field or JSON name not on the list, or on an embedded struct. TestRequestsLibrary_ProjectionCarriesNoPaths seeds files under a marker path and asserts the marker, filePath, provider ids, monitored and owner appear nowhere in the body. /author, /book, /series stay off the allow list.

S2, server built payload. POST /requests decodes with DisallowUnknownFields from a 4 KiB MaxBytesReader and accepts exactly kind, foreignId, mediaType. The server calls GetBook or GetAuthor, stores the provider's cleaned title and author, and builds payload_json itself. 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_ServerBuildsPayload sends title, rootFolderId, searchOnAdd, qualityProfileId, trailing objects and arrays: all 400. TestRequestsApprove_RevalidatesPayload tampers with the stored payload five ways: all 422 with no add.

S3, webhook text. requestCreatedPayload strips control and format characters (bidi overrides, zero width) with cleanRequestText, caps title 300, author 200, username 64 runes, and replaces every @ in all three with the fullwidth at sign. TestRequestsCreate_SendsSanitisedRequestCreated drives 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/lookup and POST /requests for 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 plus BINDERY_OIDC_DEFAULT_ROLE=requester at config and provisioning. The last admin guard now refuses a demotion to any non admin role; before, it only checked role == "user", so SetRole(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 %2F and %31), any path path.Clean would change (dot segments, doubled or trailing slash), and a leftover %, backslash or control character. HEAD matches as GET. Only r.Method is read; a chi routing method that differs from it is refused. TestRestrictRequester_DeniesEveryListedRoute covers 81 denied method and path pairs, each in HEAD, trailing slash, doubled slash, ./, x/../, upper case, %2F, %2f and escaped letter spellings. The same spellings of every allow list entry are refused too. TestRestrictRequester_IgnoresMethodOverride covers three override headers.

Plan items

Item Status
P6 matcher built once done: defaultRequesterMatcher at package init; limiter bounded with idle eviction; author progress is one grouped query per page (fillAuthorProgress)
C2 role constants done: auth.RoleAdmin/RoleUser/RoleRequester, auth.ValidRole used by all six validators; literals replaced only in files this PR touches
C7 notifier recipe done, not redesigned
T1 existing doubles stubMetaProvider, addBookBackCatalogueStub, the OIDC fakeIDP harness, vi.mock('../api/client')
T2 FuzzRequesterAllowList done: raw request lines through http.ReadRequest into a router shaped like the real one; 60 s local run, 2.3M execs, no failure
T3 concurrency TestRequestRepo_ClaimIsExclusive (8 racers, one claim) and TestRequestsApprove_ConcurrentApprovalsAddOnce (two approvals, one add, one 409), both under -race
T4 migration on populated DB TestMigration089_OnPopulatedDatabase: existing webhook keeps on_request_created = 0, users kept, table empty, kind CHECK holds
T8 OIDC demotion fail before below

Migrations

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, 5644ea10 and 72209077. The fail before evidence ran base compatible copies of the tests against 274f7f2c, the previous head of this branch, then the same files against the fix.

# Finding Fix Test Before (274f7f2c)
1 POST /requests not rate limited; failed lookups never fill the cap Route marked LimitProvider, and Create spends 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_AllowRequester 22nd create 404; third create through a 2 token guard 200
2 Pending cap was count, provider call, insert The count is a WHERE predicate inside the INSERT ... SELECT (and the reopen UPDATE); zero rows affected is the existing 429 TestRequestsCreate_PendingCapHoldsUnderConcurrency, TestRequestRepo_CreateCapIsAtomic (24 racers, cap 3, -race), TestRequestRepo_ReopenRespectsCap pending 24 against a cap of 3
3 Create and withdraw in a loop sent a webhook each time requestCreated for 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_WindowAndBound sent 5 requestCreated webhooks
4 local-only mode elevated a requester's cookie to admin; API key readable auth.Middleware lets a valid session whose role is requester through as the requester before the mode grant, in every mode; OPDSAuth refuses a requester session before its disabled and local-only bypasses; GET /auth/config withholds the key when the named user's stored role is requester (narrowed in 88ef4c0c; 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_APIKeyNeverForRequesters local-only requester GET /queue 204; /auth/config returned "apiKey":"k-requester-test"
5 Sanitiser only swapped @ notifier.SafeText (shared, in the notifier package): CleanText, then @ < > [ ] to fullwidth lookalikes. Covers <!channel>, <!here>, <@U123>, <@&role>, <link|label>, markdown links and images TestSafeText_NeutralisesChatMarkup (one case per vector), TestRequestsCreate_SendsSanitisedRequestCreated <!channel> [Free nitro](https://evil.example) passed through
6 /images unlimited for requesters LimitImage, its own bucket: 240, then 4 a second, so a library page of 60 covers is unaffected TestRestrictRequester_CreateAndImagesLimited third image through a 2 token guard 200
7 A slow add could outlive the 5 minute claim; a second approve retook it Checked first: createAuthorCore already runs the catalogue sync in a background job and returns once the author row exists; what stays synchronous is provider lookups and addBookCore'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_at is now milliseconds and 089 gains claim_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) with the claim forced stale mid add: second approval 500, 2 adds
8 Case folded keys accepted decodeStrict reads the object's keys first and refuses any key not exactly a field name TestRequests_RejectCaseFoldedKeys kind plus Kind answered 201

On item 7's before and after: with the claim forced stale by SQL (which bypasses renewal), the fixed code answers the first approval 409 rather than a 500 for either one, because the tokens stop the approvals completing or releasing each other's claims. What actually prevents a takeover is renewal, which TestRequestsApprove_SlowCoreKeepsClaim covers.

Items not taken: none.

API key narrowing, lint, and the second security review

API key narrowing (88ef4c0c). GET /auth/config now withholds the key only when the named user's stored role is requester, in every mode. Admin and user behave exactly as on main, including a role user session that local-only mode stamps admin: that session can regenerate the key, so hiding it would invite a rotation that breaks integrations. TestRequesterGuard_ModeNeverElevatesARequester now also checks that such a user session still receives the key, and TestGetConfig_APIKeyNeverForRequesters covers the matrix.

Lint (3a7de943). The required lint check (golangci-lint v2.11.4, golangci-lint run ./... with the repository .golangci.yml) failed with gosec G202 on internal/db/requests.go (request list query) and internal/db/requests_library.go (projection query). Reproduced locally with the same version, then fixed following the internal/db convention: a // #nosec G202 -- reason comment 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 72209077 and found no guard bypass. The fixes are in c50830f8, 53789c3c and c3d27b3a. Fail before evidence ran base compatible copies of the tests against 3a7de943, then the same files against the fix.

# Finding Fix Test Before (3a7de943)
1 A panicking add core left the renewer running and the request in approving defer stopRenew() (idempotent); a deferred recover releases the claim with its token and re-panics so Recoverer still logs TestRequestsApprove_PanicReleasesClaim (pending again, 0 renewers running, second approval 200; the panic still reaches the caller) status after a panicking approval approving
2 Role or epoch lookup failure let a valid requester cookie fall through to the mode grant When the mode would grant admin but a correctly signed session's role or epoch could not be read, the request goes through as that user with no role: allow list only, RequireAdmin refuses TestMiddleware_UnreadableSessionNotElevatedByMode (both failures, local-only), TestMiddleware_RequesterNotElevatedByMode (requester precedence in internal/auth, local-only and disabled, user keeps the grant) both cases: local-only served GET /queue 200
3 Cycling create and withdraw over different ids sent one webhook per create On top of the per item window, at most 10 requestCreated webhooks 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 stored TestRequestsNotify_PerOwnerCap, TestOwnerBudget_WindowAndBound 15 ids cycled sent 15 webhooks
4 Bare URLs autolink in chat SafeText replaces :// with : and two fullwidth solidi; backticks, underscores and similar are untouched TestSafeText_BareURLsDoNotLink https://evil.example/login kept
5 Dropping approving from the cap predicate survived every test New repository test with a request in approving: create and reopen at the cap are refused TestRequestRepo_CapCountsRequestsBeingApproved with the predicate mutated to 'pending' only, the create was accepted

Rebased 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 on main with git 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:

Area Resolution
internal/notifier/notifier.go, internal/models/notification.go, internal/db/notifications.go Both events kept: the bookAnnounced constant, case and column stay, and requestCreated sits beside them. Every notification statement now carries on_book_announced, on_request_created
web/src/pages/settings/NotificationsTab.tsx, web/src/api/notifications.ts, en.json Both toggles and both badges; the two save calls send both fields. The Request toggle gained aria-pressed, matching the New book toggle
web/src/App.tsx One navLabel(item) renders both badges: the adoption count on Import and the pending count on Requests. navItemsFor keeps the requester shell and appends Requests for admins
web/src/api/client.ts Both adoptionApi and requestsApi composed in
web/src/App.test.tsx Both describes kept: the Import badge tests and the requester and admin nav tests, with both API mocks
README.md Main's webhook line (which mentions new books found) kept, with the Requests bullet above it
docs/API.md One eventType row listing bookAnnounced and requestCreated; the toggle paragraph names onBookAnnounced and onRequestCreated and both migrations
cmd/bindery/requester_routes_test.go The route walk stub gained the adoption and diagnose methods, and the denied table names /library/unmatched* and /downloadclient/{id}/diagnose (3760cc96)

Discovery's announceText (in internal/api) and this PR's notifier.SafeText overlap: both strip control characters and cap length, but only SafeText neutralises mentions, Slack escapes, markdown links and URL schemes. I left announceText alone rather than change a merged feature's output here. Routing bookAnnounced through SafeText is 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), -race on 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.RoleRequester and repository calls the base refuses, so I ran base compatible copies (string literals, the requester role written with SQL) against 11aefa67 (the tip of refactor/add-book-core), then the same files against this branch.

On the base:

--- FAIL: TestFailBefore_OIDCGroupSyncKeepsRequester
    Role="user", want requester (outside the admin group, not an admin, so unchanged)
--- FAIL: TestFailBefore_LastAdminToRequester
    SetRole(last admin, requester) err = invalid role "requester": must be admin or user, want the last admin refusal
--- FAIL: TestFailBefore_RequesterGuard
    requester POST /api/v1/queue/grab: status 204, want 403
    requester GET /api/v1/queue: status 204, want 403
    requester GET /api/queue: status 204, want 403
    requester GET /api/v1/book/7/file: status 204, want 403
    requester OPDS Basic: status 200, want 403

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_LastAdminCannotBecomeRequester and TestUserMgmt_SetRole_DemoteLastAdminToRequester exist. On the base a requester role row is read as a normal user, so every route answered.

Performance

  • The allow list matcher is one chi tree built at init; a match is a tree lookup on the request path.
  • The requester limiter is O(1) per call, with a sweep at most once per 10 minutes and a map capped at 1024 entries.
  • GET /requests is two statements plus at most one grouped IN query per page for author progress.
  • GET /requests/library is a count and one page query; series and format flags are correlated subqueries backed by idx_series_books_book and idx_book_files_book_id.
  • Webhook delivery for requestCreated runs 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 user as much as requesters; the requester bucket only slows a requester filling it.

  • In disabled and local-only mode, a user session is still elevated to admin by the mode grant. This PR stops it only for requesters; changing it for user is 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 addBookParams to grow them.

Checklist

  • Tests added or updated
  • Doc-update gate cleared (docs/multi-user.md, docs/auth-oidc.md, docs/auth-proxy.md, docs/API.md, README Features)
  • Changelog fragment changelog.d/requester-role.md

Test 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/bindery pass; ./internal/api for the requests, requester, OIDC, callback, user management, OPDS, settings and add core tests pass; ./internal/db for the request repo, migration 089, notification round trip and role tests pass. The whole ./internal/db package under -race hit the 10 minute default timeout on the shared machine (a timeout, not a failure), like internal/api in make test: internal/api race package hits the 30-minute timeout #2293
  • go test -fuzz FuzzRequesterAllowList -fuzztime 60s ./internal/auth (2.3M execs, no failure)
  • make smoke equivalent: binary built with the web assets, go test ./tests/smoke/... including the new requester case
  • cd web && npx vitest run (86 files, 1018 tests), npm run build, npx tsc -b
  • eslint on touched web files: no errors

🤖 Generated with Claude Code

https://claude.ai/code/session_016fJcCVbNnKsmj2MAAMwWj9

'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] ?? ''))
vavallee added a commit that referenced this pull request Sep 17, 2026
…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>
vavallee added a commit that referenced this pull request Sep 17, 2026
…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>
vavallee added a commit that referenced this pull request Sep 17, 2026
…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>
vavallee added a commit that referenced this pull request Sep 17, 2026
… 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>
@vavallee
vavallee marked this pull request as ready for review September 17, 2026 17:15

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@vavallee
vavallee changed the base branch from refactor/add-book-core to main September 18, 2026 01:06
vavallee and others added 17 commits September 17, 2026 22:08
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>
@vavallee
vavallee force-pushed the feature/requester-role branch from c3d27b3 to 3760cc9 Compare September 18, 2026 01:30
@vavallee
vavallee merged commit cd8bfca into main Sep 18, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants