fix(books): enforce library access on file-delete write path (bookshelf-ubad) #629
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-ubad"
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
DeleteBookFileservice now accepts acheckAccess(ctx, userID, bookID) errorfunc and calls it before fetching the file record; returnsErrNotFoundon miss (no existence leak)DeleteBookFileHandlernow acceptsuserIDFromRequest func(*http.Request) int64(replacingextractUser tmpl.UserExtractor) and threadsuserIDinto the service callwire.goinjectscheckBookAccess(same func used by all read paths) anduserIDFromRequestcheckAccessfailure case; handler test asserts the userID is passed throughSecurity fix
Previously a user with
PermissionDeleteBookcould delete abook_filefrom a book in a library they had no access to —GetBookFileForDeleteonly checkedbf.id=? AND bf.book_id=? AND b.deleted=0with no library scope. The detach path's same gap is being fixed separately in jmn7.6/#628.Test plan
make testpasses (allinternal/packages green)DeleteBookFileservice test: user without library access getsErrNotFound, no file is fetched or deletedDeleteBookFileHandlertest: userID is passed to serviceCloses bead bookshelf-ubad on merge.
Code Review — bookshelf-ubad
Phase 0: DEMO Verification
No DEMO block exists in this bead (this is a security fix, not a user-facing feature change). Proceeding to spec review per instructions.
Phase 1: Spec Compliance
The PR does exactly what the spec asks:
checkAccess(wired ascheckBookAccess) is injected intoDeleteBookFileas the first curried dep.getFileownership check and before the transaction — correct placement.middleware.ErrNotFoundon miss (no existence leak: the error isfmt.Errorf("delete book file: %w", ErrNotFound), unwrappable viaerrors.Is).ErrNotFoundatdelete_file_handler.go:68— no change needed there.wire.go:677correctly threads the existingcheckBookAccessclosure (same one used by reads/LLM/reading sessions) — single source of truth.nil-safeextractUserpath is replaced withuserIDFromRequest, consistent with all other handlers.Phase 2: Code Quality
Correctness of the guard:
CheckBookAccess(service.go:660) skips the check whenuserID == 0. The old handler defaulted touserID = 0whenextractUserwas nil. The newuserIDFromRequestalways returnsd.ExtractUser(r).ID, which is the stub user ID (1) until real auth lands. The bypass is gone — correct.checkAccessproduceErrForbidden, which is wrapped atdelete_file_service.go:47but not caught by the handler switch (delete_file_handler.go:63-70). It falls to the genericfmt.Errorf("delete book file: %w", err)path, yielding a 500 instead of 403. This is pre-existing behavior shared by all other paths that usecheckBookAccess— the handler switch never checksErrForbidden. It is not introduced by this PR and is consistent with how the read path behaves today. No finding for this PR.Test quality:
[MINOR] internal/books/delete_file_service_test.go:93-101 — "does not fetch the file record" assertion tests the wrong observable
The
ItassertsremovedPathsis empty, butremovedPathsis populated byremoveFile(the disk-removal step), not bygetFile. The comment in the test acknowledges this weakness and correctly notes the real guard is theErrNotFoundassertion above. The name is misleading —removedPathsbeing empty proves the disk-removal did not run, not thatgetFilewas skipped. Suggest renaming to"does not remove the file from disk"(which matches what is actually asserted) or adding a boolean-capture to thegetFilestub. No correctness impact — theErrNotFoundItabove already catches any regression.Conventions:
var (...)declarations at top of closures: maintained.ExpectperIt: maintained.BeforeEach/JustBeforeEachseparation: correct.routes_test.goupdated in bothRegisterRoutesandRegisterRoutes permission gatessuites: complete.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Security Review — bookshelf-ubad (PR #629)
Reviewed per
.claude/rules/review-standard.md.Scope
The PR adds a
checkAccess(library-access) guard to the singleDELETE /books/{id}/files/{fileID}path. No other delete-file paths exist in the router.Finding: check position and completeness
Access check position:
checkAccessis invoked at the top ofDeleteBookFilebeforegetFile,deleteFileTx, and the diskremoveFile. This is correct — no mutation occurs before the ownership/library check.userID source:
userIDFromRequestis derived fromd.ExtractUser(r).ID, the authenticated session, consistent with every other access-checked handler in the package. Not request-body or query-param derived.Existence leak — library-access (ErrNotFound): when the user's library set does not include the book's library,
CheckBookAccessreturnsErrNotFound(wrapped).DeleteBookFilere-wraps and propagates it. The handler's switch passes it straight through (return err), yielding HTTP 404. No existence leak.Existence leak — content restriction (ErrForbidden): when a content restriction blocks access,
CheckBookAccessreturnsErrForbidden.DeleteBookFilewraps it. The handler switch does not interceptErrForbidden; it falls through toreturn fmt.Errorf("delete book file: %w", err).errors.Isunwraps transitively, so the error mapper returns HTTP 403. This is consistent with the read-pathShowhandler, which also surfacesErrForbiddenas 403 rather than normalising to 404. A user with delete permission can infer that a 403 means the book exists but is content-restricted, while a 404 means it does not exist or is library-inaccessible. This is an intentional design choice shared across the read and write paths; it mirrors the existingShowhandler semantics and is not a regression introduced by this PR.Adversarial path — can a delete-permitted user delete a file in a library they can't access? No.
checkAccessruns before any mutation.CheckBookAccessis fail-closed: an authenticated user with no library mappings sees ErrNotFound for every book; a user whose library set does not contain the book's library also sees ErrNotFound. There is no bypass.userID == 0 bypass:
CheckBookAccessskips the check whenuserID == 0. The auth middleware guardsDELETEroutes before the handler is reached, so an unauthenticated request does not arrive withuserID = 0in production. The pre-existing behaviour is unchanged and out of scope for this PR.Wire-up verified:
wire.goon branchbd-bookshelf-ubadpassescheckBookAccess(the sameCheckBookAccessinstance used byGet, reader handlers, etc.) as the first argument toDeleteBookFile, anduserIDFromRequesttoDeleteBookFileHandler. No staled.ExtractUserremains on theDeleteBookFileHandlercall site.Single route:
routes.goregisters exactly oneDELETE /books/{id}/files/{fileID}route. No "delete-formats" or bulk-file-delete secondary path exists.Test coverage
delete_file_service_test.goadds aDescribe("when the user does not have access to the book's library")block coveringErrNotFoundreturn andremovedPathsempty assertion.delete_file_handler_test.goaddsIt("passes the user ID to the delete service")and verifiesreceivedUserID == 42.Itis noted as structurally weak in the comment (assertsremovedPathsempty, not thatgetFilewas not called), but the ErrNotFound siblingItis the real guard; the comment is accurate. No correction needed.No injection / no new leak
No SQL built from request parameters; no new surface area beyond what was previously guarded by
PermissionDeleteBook.REVIEW VERDICT: 0 blocker, 0 major, 0 minor