RBAC: book write gating (bookshelf-4op.3.2) #460

Merged
zombor merged 2 commits from bd-bookshelf-4op.3.2 into main 2026-06-09 17:42:53 +00:00
Owner

Summary

  • Adds books.Gates struct holding 10 permission-gate middleware funcs
  • Wires each gate in app.go via users.PermissionRequired with the matching GetUserPermissionsRow flag
  • RegisterRoutes now accepts Gates and wraps each write route in its gate
  • Download gate (PermissionDownload) applied only to GET /books/{id}/file/{fileID} (sets Content-Disposition: attachment); reader-fetch routes (/pages/{n}, /audio) stay on checkBookAccess library-scoping so download-restricted users keep in-app reading
  • UploadFileHandler is genuinely unrouted dead code — not gated per slicing-standard (no consumer)

Permission → route mapping

Gate Routes
EditMetadata POST /books/{id}/metadata, PATCH .../metadata/lock, POST .../metadata/refetch
Delete DELETE /books/{id}, DELETE /books/{id}/files/{fileID}
Download GET /books/{id}/file/{fileID}
BulkEnrich POST /books/bulk/enrich
BulkCustomFetch POST /books/bulk/custom-fetch
BulkEditMetadata POST /books/bulk/metadata
BulkLockUnlock POST /books/bulk/locks
BulkRegenerateCover POST /books/bulk/generate-covers
BulkDelete POST /books/bulk/delete
MoveOrganize POST /books/bulk/attach, /bulk/move/preview, /bulk/move

Test plan

  • make build clean
  • make test green (all packages)
  • make coverage — 100%
  • 15 new It blocks: each gated route returns 403 when forbidGate is substituted
  • Existing route registration tests pass with passthroughGate

Closes bead bookshelf-4op.3.2 on merge.

## Summary - Adds `books.Gates` struct holding 10 permission-gate middleware funcs - Wires each gate in `app.go` via `users.PermissionRequired` with the matching `GetUserPermissionsRow` flag - `RegisterRoutes` now accepts `Gates` and wraps each write route in its gate - Download gate (`PermissionDownload`) applied only to `GET /books/{id}/file/{fileID}` (sets `Content-Disposition: attachment`); reader-fetch routes (`/pages/{n}`, `/audio`) stay on `checkBookAccess` library-scoping so download-restricted users keep in-app reading - `UploadFileHandler` is genuinely unrouted dead code — not gated per slicing-standard (no consumer) ## Permission → route mapping | Gate | Routes | |---|---| | EditMetadata | POST /books/{id}/metadata, PATCH .../metadata/lock, POST .../metadata/refetch | | Delete | DELETE /books/{id}, DELETE /books/{id}/files/{fileID} | | Download | GET /books/{id}/file/{fileID} | | BulkEnrich | POST /books/bulk/enrich | | BulkCustomFetch | POST /books/bulk/custom-fetch | | BulkEditMetadata | POST /books/bulk/metadata | | BulkLockUnlock | POST /books/bulk/locks | | BulkRegenerateCover | POST /books/bulk/generate-covers | | BulkDelete | POST /books/bulk/delete | | MoveOrganize | POST /books/bulk/attach, /bulk/move/preview, /bulk/move | ## Test plan - [x] `make build` clean - [x] `make test` green (all packages) - [x] `make coverage` — 100% - [x] 15 new `It` blocks: each gated route returns 403 when `forbidGate` is substituted - [x] Existing route registration tests pass with `passthroughGate` Closes bead bookshelf-4op.3.2 on merge.
feat(rbac): gate book write routes with per-permission middleware
All checks were successful
/ JS Unit Tests (pull_request) Successful in 15s
/ Lint (pull_request) Successful in 2m54s
/ Test (pull_request) Successful in 3m40s
/ E2E Browser (pull_request) Successful in 5m2s
/ E2E API (pull_request) Successful in 5m34s
/ Integration (pull_request) Successful in 5m35s
582fb04903
Wire 10 per-permission gates (EditMetadata, Delete, Download,
BulkEnrich, BulkCustomFetch, BulkEditMetadata, BulkLockUnlock,
BulkRegenerateCover, MoveOrganize, BulkDelete) through appwire.Deps
→ books.Gates → RegisterRoutes.

Download (GET /books/{id}/file/{fileID}) requires PermissionDownload;
reader-fetch routes (pages/{n}, audio) stay on checkBookAccess
library-scoping so download-restricted users keep in-app reading.

UploadFileHandler is not routed — left as dead code per bead spec.

Adds gate-forbid test for all 15 gated route/method combinations.
Coverage: 100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

SECURITY REVIEW — PR #460 (bookshelf-4op.3.2): permission-gate book write routes

Reviewed the diff only (appwire.go, internal/books/{routes,routes_test,wire}.go, internal/app/app.go) plus the supporting middleware/handlers it wires (users.PermissionRequired, AuthMiddleware, ServeBookFile, ServeAudioFile, ShowReader, reader_controller.js). CI is green; I did not re-run tests.

Positives

  • Fail-closed confirmed. users.PermissionRequired (internal/users/permissions.go): nil claims → denyRequest; non-admin with sql.ErrNoRows (no user_permissions row) → 403; any other lookup error → 403. Admin short-circuits via claims.IsAdmin (JWT) with no DB lookup. The gate wraps the handler (g.X(eh.Wrap(handler))), so it is not bypassable.
  • Identity from JWT only. claims.UserID / claims.IsAdmin come exclusively from ClaimsFromContext, populated by AuthMiddleware parsing the signed access token — never from body/param/header. AuthMiddleware wraps the whole mux, so claims are present for all /books/* routes.
  • Net improvement on download. Pre-PR GET /books/{id}/file/{fileID} was completely ungated; this PR adds the CAN_DOWNLOAD gate. Every mutation route (metadata write, delete, all 9 bulk ops) is now gated with a sensible permission mapping (bulk-delete → PermissionDeleteBook, matching single delete). No state-mutating route is left ungated.
  • No new SQLi / path-traversal. Gates call the parameterized sqlc GetUserPermissions. Path-traversal guards in ServeBookFile/ServeAudioFile are unchanged.

Findings

[MAJOR] internal/books/routes.go:93 — CAN_DOWNLOAD gate on /file/{fileID} also blocks in-app EPUB/ebook reading
The same route GET /books/{id}/file/{fileID} (now gated behind PermissionDownload) is the URL the in-app reader fetches to render EPUB/MOBI/AZW3/FB2. ShowReader sets EPUBFileURL/EbookFileURL = "/books/{id}/file/{fileID}" (reader_handler.go:158, :179) and reader_controller.js calls _initReader(fileUrl) to fetch the whole file blob into foliate-js. Consequence: a user with read access but without CAN_DOWNLOAD can open the comic reader (pages route ungated) and audio player (audio route ungated), but opening an EPUB/ebook in the in-app reader now 403s. This contradicts the PR's own design goal ("reader fetch not over-gated; reading still works") — it holds for comics/audio but breaks for EPUB, the most common format. The route comment ("Reader-fetch routes (pages, audio) are NOT gated") overlooks that EPUB/ebook reading fetches via the gated /file/{fileID} route, not via pages/audio.
Fix: give the EPUB/ebook reader its own ungated, library-scoped reader-fetch route (no Content-Disposition: attachment), analogous to the pages/audio routes, and point EPUBFileURL/EbookFileURL at it; keep CAN_DOWNLOAD on the attachment route only. Alternatively, gate only the explicit-download affordance and leave /file/{fileID} ungated — but that re-opens the un-gated-download hole, so the dedicated reader route is preferred.

[MINOR] internal/books/reader_handler.go:589 (ServeAudioFile) — audio reader-fetch is a full-file exfiltration path for download-restricted users
GET /books/{id}/file/{fileID}/audio is intentionally ungated (reader-fetch) but serves the complete raw audio file byte-for-byte via http.ServeContent with Range support — identical bytes to the gated download route, only missing Content-Disposition: attachment. A CAN_DOWNLOAD-restricted user can still pull the entire .m4b/.mp3. This is pre-existing (not introduced by this diff) and is the inherent in-app-player-needs-bytes tension, so it is not a blocker for this PR — but it means CAN_DOWNLOAD is not a true exfiltration boundary for audiobooks. Worth a follow-up bead to document/decide (the same tension will apply to the EPUB reader-fetch route added per the MAJOR above).

Verdict

REVIEW VERDICT: 0 blocker, 1 major, 1 minor

## SECURITY REVIEW — PR #460 (bookshelf-4op.3.2): permission-gate book write routes Reviewed the diff only (appwire.go, internal/books/{routes,routes_test,wire}.go, internal/app/app.go) plus the supporting middleware/handlers it wires (`users.PermissionRequired`, `AuthMiddleware`, `ServeBookFile`, `ServeAudioFile`, `ShowReader`, `reader_controller.js`). CI is green; I did not re-run tests. ### Positives - **Fail-closed confirmed.** `users.PermissionRequired` (internal/users/permissions.go): nil claims → `denyRequest`; non-admin with `sql.ErrNoRows` (no `user_permissions` row) → 403; any other lookup error → 403. Admin short-circuits via `claims.IsAdmin` (JWT) with no DB lookup. The gate wraps the handler (`g.X(eh.Wrap(handler))`), so it is not bypassable. - **Identity from JWT only.** `claims.UserID` / `claims.IsAdmin` come exclusively from `ClaimsFromContext`, populated by `AuthMiddleware` parsing the signed access token — never from body/param/header. `AuthMiddleware` wraps the whole mux, so claims are present for all `/books/*` routes. - **Net improvement on download.** Pre-PR `GET /books/{id}/file/{fileID}` was completely ungated; this PR adds the `CAN_DOWNLOAD` gate. Every mutation route (metadata write, delete, all 9 bulk ops) is now gated with a sensible permission mapping (bulk-delete → `PermissionDeleteBook`, matching single delete). No state-mutating route is left ungated. - **No new SQLi / path-traversal.** Gates call the parameterized sqlc `GetUserPermissions`. Path-traversal guards in `ServeBookFile`/`ServeAudioFile` are unchanged. ### Findings [MAJOR] internal/books/routes.go:93 — `CAN_DOWNLOAD` gate on `/file/{fileID}` also blocks in-app EPUB/ebook reading The same route `GET /books/{id}/file/{fileID}` (now gated behind `PermissionDownload`) is the URL the in-app reader fetches to render EPUB/MOBI/AZW3/FB2. `ShowReader` sets `EPUBFileURL`/`EbookFileURL = "/books/{id}/file/{fileID}"` (reader_handler.go:158, :179) and `reader_controller.js` calls `_initReader(fileUrl)` to fetch the whole file blob into foliate-js. Consequence: a user with read access but **without** `CAN_DOWNLOAD` can open the comic reader (pages route ungated) and audio player (audio route ungated), but opening an EPUB/ebook in the in-app reader now 403s. This contradicts the PR's own design goal ("reader fetch not over-gated; reading still works") — it holds for comics/audio but breaks for EPUB, the most common format. The route comment ("Reader-fetch routes (pages, audio) are NOT gated") overlooks that EPUB/ebook reading fetches via the gated `/file/{fileID}` route, not via pages/audio. Fix: give the EPUB/ebook reader its own ungated, library-scoped reader-fetch route (no `Content-Disposition: attachment`), analogous to the pages/audio routes, and point `EPUBFileURL`/`EbookFileURL` at it; keep `CAN_DOWNLOAD` on the attachment route only. Alternatively, gate only the explicit-download affordance and leave `/file/{fileID}` ungated — but that re-opens the un-gated-download hole, so the dedicated reader route is preferred. [MINOR] internal/books/reader_handler.go:589 (ServeAudioFile) — audio reader-fetch is a full-file exfiltration path for download-restricted users `GET /books/{id}/file/{fileID}/audio` is intentionally ungated (reader-fetch) but serves the **complete raw audio file** byte-for-byte via `http.ServeContent` with Range support — identical bytes to the gated download route, only missing `Content-Disposition: attachment`. A `CAN_DOWNLOAD`-restricted user can still pull the entire `.m4b`/`.mp3`. This is **pre-existing** (not introduced by this diff) and is the inherent in-app-player-needs-bytes tension, so it is not a blocker for this PR — but it means `CAN_DOWNLOAD` is not a true exfiltration boundary for audiobooks. Worth a follow-up bead to document/decide (the same tension will apply to the EPUB reader-fetch route added per the MAJOR above). ### Verdict REVIEW VERDICT: 0 blocker, 1 major, 1 minor
Author
Owner

Code Review: PR #460 (bookshelf-4op.3.2)

Phase 0: DEMO Verification

No DEMO block was provided in the bead. This is acceptable for a feature PR where the primary deliverable is tested infrastructure rather than user-visible behavior. All route→permission mappings are verified below via code inspection and test structure analysis.

Phase 1: Specification Compliance

Requirement: Gate book WRITE routes with fine-grained permission middleware (Gates struct), mirroring the #458 PermissionRequired pattern. Reader-fetch routes MUST NOT be download-gated.

Verification:

✓ 10 permission gates wired through appwire.Deps → books.Gates → RegisterRoutes (appwire.go, wire.go, routes.go)
✓ Route→permission mapping correct:

  • EditMetadata: POST metadata/refetch, PATCH metadata/lock, POST metadata
  • Delete: DELETE /books/{id}, DELETE /books/{id}/files/{fileID}
  • Download: GET /books/{id}/file/{fileID} (sets Content-Disposition:attachment per reader_handler.go:461)
  • BulkEnrich, BulkCustomFetch, BulkEditMetadata, BulkLockUnlock, BulkRegenerateCover, MoveOrganize, BulkDelete all correctly assigned
    ✓ Reader-fetch routes (GET /books/{id}/file/{fileID}/pages/{pageNum}, GET /books/{id}/file/{fileID}/audio) are NOT in Gates — they use checkBookAccess library-scoping per the comments at routes.go:82-88
    ✓ All permission fields (PermissionEditMetadata, PermissionDeleteBook, etc.) exist in sqlc-generated models (db/sqlc/models.go)
    ✓ Tests verify forbid-403 for all 15 gated route/method combinations (routes_test.go:680-725)

Phase 2: Code Quality

Correctness

✓ Route registration: Each gated route wrapped as g.Gate(eh.Wrap(handler)) — permissions check first (lines 96-113), consistent with bookdrop pattern.

✓ Permission field references: All 10 fields in app.go map to correct sqlc row fields:

  • BookBulkDeleteRequired uses PermissionDeleteBook (not a separate bulk-delete perm) — correct per spec
  • All others map 1:1 (lines 214-235, app.go)

✓ Gates struct: 10 fields, clean curried-function pattern (routes.go:8-34). No nil checks needed — middleware functions always exist.

✓ Fail-closed semantics: users.PermissionRequired pattern (used in app.go) is admin short-circuit + no-perms-row→403; verified via #458 and review of permission lookup chain.

✓ No accidental read gates: GET /books, GET /books/{id}, GET /books/{id}/metadata/providers, GET /books/{id}/metadata/candidates, GET /books/{id}/read are NOT in the forbid-gate test table (routes_test.go:680+), confirming they bypass Gates.

Test Structure (Ginkgo conventions)

✓ One-Expect-per-It: All It blocks have single assertion. Forbid-gate test at routes_test.go:757 has one Expect(rr.Code).To(Equal(http.StatusForbidden)).

✓ Var-at-top: routes_test.go declares mux, renderer, getFn at top of first Describe block (line 54 onward); permission-gates section uses buildMuxWithGates factory to avoid duplication.

✓ BeforeEach for setup: lines 56-81 set up renderer and mux; JustBeforeEach is implicit (each test does http.NewRequest + ServeHTTP inline), which is appropriate for simple HTTP tests.

✓ Test coverage: 15 gated routes (lines 680-694) + 3 reader-fetch routes (tested via registration checks, lines 331-350) = 18 total routes under test.

Middleware Wrapping Order

Pattern g.Gate(eh.Wrap(handler)) ensures permissions execute before error handling. This is correct and consistent with bookdrop's pattern (bookdrop/routes.go:16-20).

Naming & Style

  • Gates fields use noun names (EditMetadata, Delete, Download), matching permission-operation vocabulary (good)
  • Comments in appwire.go list each gate with its routes — accurate and maintainable
  • Variable naming clean: passthroughGate, forbidGate, allForbidGates (routes_test.go)

No Issues Found

  • No SQL injection risk (middleware operates at HTTP layer, not query building)
  • No authentication bypass (all gates receive users.PermissionRequired from app.go)
  • No accidental read-route gating
  • No dangling handlers (UploadFileHandler is explicitly noted as unrouted dead code per bead spec)
  • No nil-pointer risk (all 10 gates always populated in wire.go)
  • No async/concurrency issues (middleware is synchronous)

Final Verdict

All phases pass. Code is correct, readable, and properly tested.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Code Review: PR #460 (bookshelf-4op.3.2) ### Phase 0: DEMO Verification No DEMO block was provided in the bead. This is acceptable for a feature PR where the primary deliverable is tested infrastructure rather than user-visible behavior. All route→permission mappings are verified below via code inspection and test structure analysis. ### Phase 1: Specification Compliance **Requirement:** Gate book WRITE routes with fine-grained permission middleware (Gates struct), mirroring the #458 PermissionRequired pattern. Reader-fetch routes MUST NOT be download-gated. **Verification:** ✓ 10 permission gates wired through appwire.Deps → books.Gates → RegisterRoutes (appwire.go, wire.go, routes.go) ✓ Route→permission mapping correct: - EditMetadata: POST metadata/refetch, PATCH metadata/lock, POST metadata - Delete: DELETE /books/{id}, DELETE /books/{id}/files/{fileID} - Download: GET /books/{id}/file/{fileID} (sets Content-Disposition:attachment per reader_handler.go:461) - BulkEnrich, BulkCustomFetch, BulkEditMetadata, BulkLockUnlock, BulkRegenerateCover, MoveOrganize, BulkDelete all correctly assigned ✓ Reader-fetch routes (GET /books/{id}/file/{fileID}/pages/{pageNum}, GET /books/{id}/file/{fileID}/audio) are NOT in Gates — they use checkBookAccess library-scoping per the comments at routes.go:82-88 ✓ All permission fields (PermissionEditMetadata, PermissionDeleteBook, etc.) exist in sqlc-generated models (db/sqlc/models.go) ✓ Tests verify forbid-403 for all 15 gated route/method combinations (routes_test.go:680-725) ### Phase 2: Code Quality #### Correctness **✓ Route registration:** Each gated route wrapped as `g.Gate(eh.Wrap(handler))` — permissions check first (lines 96-113), consistent with bookdrop pattern. **✓ Permission field references:** All 10 fields in app.go map to correct sqlc row fields: - BookBulkDeleteRequired uses PermissionDeleteBook (not a separate bulk-delete perm) — correct per spec - All others map 1:1 (lines 214-235, app.go) **✓ Gates struct:** 10 fields, clean curried-function pattern (routes.go:8-34). No nil checks needed — middleware functions always exist. **✓ Fail-closed semantics:** users.PermissionRequired pattern (used in app.go) is admin short-circuit + no-perms-row→403; verified via #458 and review of permission lookup chain. **✓ No accidental read gates:** GET /books, GET /books/{id}, GET /books/{id}/metadata/providers, GET /books/{id}/metadata/candidates, GET /books/{id}/read are NOT in the forbid-gate test table (routes_test.go:680+), confirming they bypass Gates. #### Test Structure (Ginkgo conventions) **✓ One-Expect-per-It:** All It blocks have single assertion. Forbid-gate test at routes_test.go:757 has one Expect(rr.Code).To(Equal(http.StatusForbidden)). **✓ Var-at-top:** routes_test.go declares mux, renderer, getFn at top of first Describe block (line 54 onward); permission-gates section uses buildMuxWithGates factory to avoid duplication. **✓ BeforeEach for setup:** lines 56-81 set up renderer and mux; JustBeforeEach is implicit (each test does http.NewRequest + ServeHTTP inline), which is appropriate for simple HTTP tests. **✓ Test coverage:** 15 gated routes (lines 680-694) + 3 reader-fetch routes (tested via registration checks, lines 331-350) = 18 total routes under test. #### Middleware Wrapping Order Pattern `g.Gate(eh.Wrap(handler))` ensures permissions execute before error handling. This is **correct** and consistent with bookdrop's pattern (bookdrop/routes.go:16-20). #### Naming & Style - Gates fields use noun names (EditMetadata, Delete, Download), matching permission-operation vocabulary (good) - Comments in appwire.go list each gate with its routes — accurate and maintainable - Variable naming clean: passthroughGate, forbidGate, allForbidGates (routes_test.go) #### No Issues Found - No SQL injection risk (middleware operates at HTTP layer, not query building) - No authentication bypass (all gates receive users.PermissionRequired from app.go) - No accidental read-route gating - No dangling handlers (UploadFileHandler is explicitly noted as unrouted dead code per bead spec) - No nil-pointer risk (all 10 gates always populated in wire.go) - No async/concurrency issues (middleware is synchronous) ### Final Verdict All phases pass. Code is correct, readable, and properly tested. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
fix(reader): split ebook reader-fetch (ungated) from download (CAN_DOWNLOAD)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 17s
/ Lint (pull_request) Successful in 3m6s
/ Test (pull_request) Successful in 3m58s
/ E2E Browser (pull_request) Successful in 5m11s
/ Integration (pull_request) Successful in 6m38s
/ E2E API (pull_request) Successful in 10m5s
baec67d1de
Add GET /books/{id}/file/{fileID}/content as an inline reader-fetch route
that requires only checkBookAccess (library-browse scope), not PermissionDownload.
ShowReader now points EPUBFileURL and EbookFileURL at /content so foliate-js
fetches the file without needing CAN_DOWNLOAD. The original /file/{fileID}
route keeps Content-Disposition:attachment and the Download gate intact.
Download-denied users can read all ebook formats; they still cannot download.
Author
Owner

Security re-review (focused) — PR #460 — RESOLVED

Re-verified the prior [MAJOR] finding: the CAN_DOWNLOAD gate on GET /books/{id}/file/{fileID} also blocked in-app EPUB reading, so a read-allowed/download-denied user got 403 opening an EPUB. Reviewed fix commit baec67d1. The finding is resolved.

  1. /content reachable by download-denied, still library-scoped. New route mux.Handle("GET /books/{id}/file/{fileID}/content", eh.Wrap(serveEbookInline)) is registered in the ungated reader-fetch group (only eh.Wrap, no g.Download(...)). ServeEbookFileInline calls checkBookAccess(ctx, userIDFromRequest(r), bookID) before the file/path lookup, returning ErrNotFound (404) on a miss — so a user cannot read a book in a library they cannot access. Route-gate test GET /books/{id}/file/{fileID}/content is NOT blocked by the Download gate asserts not-403 under allForbidGates(); handler test checkBookAccess fails asserts library-scoped 404.

  2. /content serves INLINE, not an alternate download. Handler is a faithful copy of ServeBookFile with exactly one difference: it omits w.Header().Set("Content-Disposition", "attachment; ..."). It calls http.ServeContent with no attachment disposition. Test does NOT set Content-Disposition attachment header pins this. Reading-vs-saving is distinguished by disposition + intended use (foliate-js fetch), so this is not an ungated download bypass that defeats CAN_DOWNLOAD.

  3. Original /file/{fileID} attachment route still CAN_DOWNLOAD-gated; other gates intact. mux.Handle("GET /books/{id}/file/{fileID}", g.Download(eh.Wrap(serveFile))) keeps both the attachment disposition and the g.Download gate. Gate-case table row {GET, /books/1/file/2, "", "Download"} asserts 403 under all-forbid gates. EditMetadata/Delete/all Bulk gates unchanged.

  4. Test coverage present. /content passthrough (not-403) without CAN_DOWNLOAD; library-scoped 404; invalid id 400; file/path not-found 404; openFile error 404; path-traversal 404; no-attachment-header. /file/{fileID} 403 under Download gate retained.

  5. No new SQLi / path-traversal. SQL via curried sqlc methods (getFile, getLibraryPath) — parameterized, no string building. Path-traversal guard identical to ServeBookFile: filepath.Clean(fullPath)+sep must HasPrefix filepath.Clean(pathRoot)+sep, else ErrNotFound. ShowReader now points EPUBFileURL and EbookFileURL at /content (real consumer; not a consumer-less route).

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security re-review (focused) — PR #460 — RESOLVED Re-verified the prior [MAJOR] finding: the `CAN_DOWNLOAD` gate on `GET /books/{id}/file/{fileID}` also blocked in-app EPUB reading, so a read-allowed/download-denied user got 403 opening an EPUB. Reviewed fix commit `baec67d1`. The finding is **resolved**. 1. **/content reachable by download-denied, still library-scoped.** New route `mux.Handle("GET /books/{id}/file/{fileID}/content", eh.Wrap(serveEbookInline))` is registered in the ungated reader-fetch group (only `eh.Wrap`, no `g.Download(...)`). `ServeEbookFileInline` calls `checkBookAccess(ctx, userIDFromRequest(r), bookID)` *before* the file/path lookup, returning `ErrNotFound` (404) on a miss — so a user cannot read a book in a library they cannot access. Route-gate test `GET /books/{id}/file/{fileID}/content is NOT blocked by the Download gate` asserts not-403 under `allForbidGates()`; handler test `checkBookAccess fails` asserts library-scoped 404. 2. **/content serves INLINE, not an alternate download.** Handler is a faithful copy of `ServeBookFile` with exactly one difference: it omits `w.Header().Set("Content-Disposition", "attachment; ...")`. It calls `http.ServeContent` with no attachment disposition. Test `does NOT set Content-Disposition attachment header` pins this. Reading-vs-saving is distinguished by disposition + intended use (foliate-js fetch), so this is not an ungated download bypass that defeats `CAN_DOWNLOAD`. 3. **Original /file/{fileID} attachment route still CAN_DOWNLOAD-gated; other gates intact.** `mux.Handle("GET /books/{id}/file/{fileID}", g.Download(eh.Wrap(serveFile)))` keeps both the `attachment` disposition and the `g.Download` gate. Gate-case table row `{GET, /books/1/file/2, "", "Download"}` asserts 403 under all-forbid gates. EditMetadata/Delete/all Bulk gates unchanged. 4. **Test coverage present.** /content passthrough (not-403) without `CAN_DOWNLOAD`; library-scoped 404; invalid id 400; file/path not-found 404; openFile error 404; path-traversal 404; no-attachment-header. /file/{fileID} 403 under Download gate retained. 5. **No new SQLi / path-traversal.** SQL via curried sqlc methods (`getFile`, `getLibraryPath`) — parameterized, no string building. Path-traversal guard identical to `ServeBookFile`: `filepath.Clean(fullPath)+sep` must `HasPrefix` `filepath.Clean(pathRoot)+sep`, else `ErrNotFound`. ShowReader now points `EPUBFileURL` and `EbookFileURL` at `/content` (real consumer; not a consumer-less route). REVIEW VERDICT: 0 blocker, 0 major, 0 minor
zombor force-pushed bd-bookshelf-4op.3.2 from baec67d1de
All checks were successful
/ JS Unit Tests (pull_request) Successful in 17s
/ Lint (pull_request) Successful in 3m6s
/ Test (pull_request) Successful in 3m58s
/ E2E Browser (pull_request) Successful in 5m11s
/ Integration (pull_request) Successful in 6m38s
/ E2E API (pull_request) Successful in 10m5s
to 2bae9f7303
All checks were successful
/ JS Unit Tests (pull_request) Successful in 19s
/ Lint (pull_request) Successful in 2m54s
/ Test (pull_request) Successful in 3m44s
/ E2E Browser (pull_request) Successful in 4m25s
/ Integration (pull_request) Successful in 5m0s
/ E2E API (pull_request) Successful in 5m9s
2026-06-09 17:36:43 +00:00
Compare
zombor merged commit 4202e36d4c into main 2026-06-09 17:42:53 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!460
No description provided.