fix(opds): enforce permission_access_opds and permission_download (bookshelf-t582g.4.1) #1249
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t582g.4.1"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
permission_access_opdsandpermission_downloadwere surfaced in the admin UI and OIDC group mappings but never enforced inhandler.gorequireOpdsAccess(→permission_access_opds) andrequireDownload(→permission_download) guards as shared helpers inhandler.go; both returnmiddleware.ErrForbidden(403) for authenticated users lacking the permissionRootHandlerandBooksHandlergate onpermission_access_opds;DownloadHandlerandCoverHandlergate onpermission_downloadwire.go: wiresusers.GetAdminUserPermissions(d.Q.GetUserPermissions)once and passes it to all four handlers;Wirefunction held at exactly 60 lines (funlen gate)Test plan
permissions_test.gocovers all 4 handlers × (has-perm / lacks-perm / db-error) = 12 new specsmake lintclean (no opds-specific findings)make testsuite greenCloses bead bookshelf-t582g.4.1 on merge.
Security review — PR #1239 (
bd-bookshelf-cs2zl.2)New
POST /books/bulk/filter/scan-file(LLM scan-file over all filter-matching / whole-library books) + library kebab trigger.Multi-user scoping — PASS (fail-closed).
bulkScanFileFilterRequestreuses the sharedbulkFilterHandler:userIDis taken from the session (userIDFromRequest(r)), never the body;library_id/shelf_id/statusare re-scoped to that userID (ShelfUserID/StatusUserID);magic_shelf_idis ownership-checked viacheckMagicShelfAccess(404 on miss). Book-ID resolution re-resolvesuserLibraryIDsper ContinueAsNew epoch inbuildListFilteredIDsPageFnand passes them toListFilteredBookIDsPage, whose predicate is fail-closed (internal/books/filter_predicates.go:104→library_id IN (...); non-nil empty →1=0).users.GetUserLibraryIDsnormalizesnil → []int64{}, so a zero-library user takes the1=0branch — a body-suppliedlibrary_idthe user cannot access yields no rows, not a cross-user leak.Auth — PASS. Route is gated
g.BulkScanFile(...)→BookBulkScanFileRequired→users.PermissionRequired(..., PermissionBulkAutoFetchMetadata)(internal/app/app.go:355), same real permission as the sibling by-IDs endpoint. Not "any logged-in user."Resource-exhaustion / cost DoS — PASS. Fan-out is bounded single-digit (
defaultFanOutConcurrency = 4,internal/wfengine/fanout.go:19) and sub-workflows route to the LLM queue (scanFileFanOutOptions) so vision activities respect the GPU/concurrency cap; the kebab entry is gated behind{{if $.LLMVisionAvailable}}and adialog.confirmcount prompt.Injection — PASS. All SQL is sqlc/parameterized;
view_queryvalidated viaParseViewQueryFilterat the boundary; status/format/metadata filters allowlist-validated; audit action is a constant.Workflow versioning — SAFE (no gate needed). The new
case BulkFilterOpScanFileinbulkByFilterApplyOpis selected by the per-instance-immutableinput.Op; in-flight instances carry a differentOpand keep their original command sequence, so replay does not diverge.Findings
[MINOR] templates/layouts/base.html:200 — count-confirmation shows 0 for the largest libraries
data-...-book-count-value="{{if .HasCount}}{{.Count}}{{else}}0{{end}}"falls back to 0 when the count is unavailable (HasCountfalse) — which per the Scale convention is exactly the large/unfiltered libraries where the "you are about to scan N files … uses your LLM budget" confirmation matters most. The most expensive case shows the least alarming number, weakening the secondary cost guard. Authorization/scoping/permission gates still fully protect the operation, so this is UX-quality, not a vulnerability. Suggest an indeterminate message ("all files in this library") whenHasCountis false rather than "0 files".REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code Review: OPDS Permission Enforcement (bd-bookshelf-t582g.4.1)
Summary
This PR implements enforcement of
permission_access_opdson catalog/feed endpoints andpermission_downloadon file/cover download routes, closing a previously unchecked authorization gap.Findings
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Verification Results
✓ Coverage of all OPDS entry points: All 5 routes are properly gated
permission_access_opdspermission_access_opdspermission_downloadpermission_downloadpermission_download✓ Enforcement order (authentication → permission check): All handlers correctly call
authenticate()first, thenrequireOpdsAccess()orrequireDownload()immediately after, before any business logic.✓ Fail-closed behavior: Permission checks return
middleware.ErrForbidden(→ 403) when permission is absent, and wrap/return any DB errors as 500s. No fallthrough to allow when permission load fails.✓ Shared helpers clean: Helper functions
requireOpdsAccess()andrequireDownload()are concise, properly wrap errors with context, and correctly check the respective permission fields (AccessOpds,Download) from theUserPermissionsstruct.✓ Curried dependency injection:
getUserPermissionsis bound once at wire time viausers.GetAdminUserPermissions(d.Q.GetUserPermissions)and passed to each handler, following the projects functional-arg pattern.✓ Wire format unchanged for permitted users: No changes to XML response structures, element names, or link generation. OPDS feed format remains wire-compatible.
✓ Black-box test coverage:
permissions_test.gousespackage opds_testand exercises only exported functions and types. Three test cases per endpoint:✓ Existing tests updated: All pre-existing tests in
handler_test.gonow pass theallPermsGrantedstub to prevent permission checks from interfering with their test assertions (proper test isolation).All requirements satisfied. No security vulnerabilities, no correctness issues, no convention violations detected.
Security Review — PR #1249 (OPDS authz gate,
bd-bookshelf-t582g.4.1)Adversarial focus: OPDS route gating, fail-closed, byte-fetch prevention, enumeration leak, multi-user scoping.
Route enumeration — all 5 OPDS routes are registered solely via
opds.Wire->RegisterRoutes(internal/opds/routes.go);opds.Wire(internal/app/app.go:88) is the only registration site and now passesgetUserPermissionsto every handler:GET /opds-> RootHandler ->requireOpdsAccess(permission_access_opds) — GATEDGET /opds/books-> BooksHandler ->requireOpdsAccess— GATEDGET /opds/books/{id}/file/{fileID}-> DownloadHandler ->requireDownload(permission_download) — GATEDGET /opds/books/{id}/cover-> CoverHandler ->requireDownload— GATEDGET /opds/books/{id}/thumbnail-> CoverHandler ->requireDownload— GATEDThere is no search/acquisition route beyond these. No un-gated OPDS handler remains — the bypass is closed.
Five focus areas:
authenticate, BEFOREgetFile/openFile. It blocks reading the file bytes, not merely hiding the acquisition link.requireOpdsAccess/requireDownloadreturn a wrapped error ongetUserPermissionsfailure -> error middleware -> 500 (no content served).GetAdminUserPermissions(internal/users/admin_service.go:384-391) returns emptyUserPermissions{}onsql.ErrNoRows(missing user -> all-false -> 403) and a wrapped error otherwise. Fail-closed in both directions.middleware.ErrForbiddenmaps to 403 (internal/middleware/error_mapper.go:99).getUserLibraryIDs+getContentRestrictions(feed) andcheckBookAccess(download/cover) all remain; the permission gate is IN ADDITION TO, not instead of, library scoping.Tests cover has/lacks/DB-error (403/500) for all four handlers, black-box
package opds_test, one-Expect-per-It.No findings.
REVIEW VERDICT: 0 blocker, 0 major, 0 minor
96f74a78c8d34baaf22f