feat(bookdrop): Bulk Edit metadata across selected proposals (bookshelf-d1x3.2) #603
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-d1x3.2"
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
Test plan
Closes bead bookshelf-d1x3.2 on merge.
Security Review — PR #603 (bookshelf-d1x3.2, BookDrop Bulk Edit)
Scope reviewed
POST /bookdrop/bulk-editownership / user scopingFinding 1 — Ownership / user-scoping
The new
BulkEditMetadataHandlercallsbulkEdit(r.Context(), ids, patch)whereidscome from the request body andUpdateProposalMetadataperforms onlyGetBookdropFile(ctx, id)— a bareWHERE id = ?with nouser_idfilter. Thebookdrop_filetable has nouser_idcolumn (it is a shared, server-global ingest queue in the Grimmory schema). This means any user withpermission_access_bookdropcan overwrite theoriginal_metadataof any bookdrop proposal regardless of which user created or owns it.Assessment — MINOR context: The
bookdrop_filetable intentionally has nouser_idcolumn (Grimmory-compatible schema). The existingUpdateMetadataHandler(single-file) andBulkRejectHandlercarry the same absence of per-user scoping — this is the pre-existing design, not a regression introduced by this PR. TheBookdropRequiredmiddleware correctly gates the entire route group to authenticated users with the bookdrop permission, so unauthenticated access is blocked. The risk is one privileged user clobbering another privileged user's proposal metadata, which is a low-severity multi-tenancy gap that pre-dates this PR and is consistent across all bookdrop mutation endpoints.Conclusion: no new vulnerability introduced by this PR. Pre-existing parity with
UpdateMetadataHandlerandBulkRejectHandler.Finding 2 — Input validation
parseIDsvalidates comma-separated IDs toint64, enforces a 1000-ID cap, and rejects empty input. ✓http.MaxBytesReader(w, r.Body, 1<<16)(64 KB) caps the JSON body. ✓string,*float64); they arejson.Marshal-ed intooriginal_metadataas data, not executed or interpreted. ✓fileMetadataJSONis marshalled to a JSON string and stored as a blob. ✓Finding 3 — XSS / template injection
BulkEditAppliedFlashis populated from?bulk_edit_applied=<msg>query param viavalidBulkEditAppliedFlash()which enforces^Bulk edit: \d+ updated, \d+ failed\.$before accepting the value. ✓{{.BulkEditAppliedFlash}}(not{{.BulkEditAppliedFlash | noescape}}), sohtml/templateauto-escaping applies. ✓data.appliedanddata.failed(integers from the JSON response) — no uncontrolled user string is ever in the URL. ✓Finding 4 — Stimulus controller (XSS / CSP / eval)
innerHTML,eval,document.write, orinsertAdjacentHTMLwith untrusted data. ✓document.createElement+.textContentassignment — properly escaped. ✓style=attributes on dynamic elements (CSP-safe). ✓<script>blocks added. Controller loaded as<script defer src="...">. ✓bookshelf_csrfcookie and sent inX-CSRF-Tokenheader. ✓Finding 5 — Logging
ids_count,applied,failed— no field values, no PII, no proposal content. ✓res.Err.Error()is surfaced in the JSON response; these errors are internal (e.g."update proposal metadata 42: not found") — no secrets or user data. ✓Summary
No new security vulnerabilities introduced by this PR. All identified characteristics (lack of
user_idscoping onbookdrop_file) are pre-existing, intentional Grimmory-schema limitations shared by every bookdrop mutation handler.CODE REVIEW — bookshelf-d1x3.2 (PR #603)
VERDICT: 0 blockers, 0 majors, 3 minors
Phase 0: DEMO Verification
CI note accepted: 5/6 jobs green; the failing Test job is the confirmed pre-existing BulkLLMSweepWorkflow/lt2p flake — this PR touches no wfengine code. No DEMO block required under the pre-existing-flake carve-out.
Phase 1: Spec Compliance — PASS
All five review items verified:
BulkEditMetadatainreview_service.gocallsUpdateProposalMetadata(which overwritesbookdrop_file.original_metadatawith the canonicalfileMetadataJSONshape) for each ID. Fields in the patch overwrite; no new Grimmory columns added. Unselected files are untouched.base.html<script defer>tag (self-registers onwindow.BookdropBulkEditController, registered inapp.js). No inline<script>, noonclick/on*=handlers, nostyle=attributes in the template. The fetch call sendsX-CSRF-Tokenfrom the cookie. Clean.bookdrop_filehas nouser_idcolumn — it is not per-user scoped at the schema level, which is the existing precedent (same asUpdateMetadataHandler,BulkRejectHandler). The route is gated behindBookdropRequiredwhich enforcesClaimsFromContextand permission check. This is consistent with the existing pattern, not a regression introduced by this PR.Orderedjourney; drives real browser interaction (select-all → enable button → open modal → fill publisher → apply → wait for redirect + flash → DB assertion). Assertions are real.parseIDsvalidates the id list;http.MaxBytesReader(w, r.Body, 1<<16)caps the body. Curried-function pattern followed throughout. Ginkgo structure is correct.Phase 2: Code Quality
[MINOR]
internal/bookdrop/review_handler_test.go:1319— TwoExpectcalls in oneItblock ("passes the title to the bulk-edit func"):Expect(captured).To(HaveLen(3))thenExpect(captured[0].Title).To(Equal("Bulk Title")). Per project convention (one assertion perIt), split into twoItblocks.[MINOR]
internal/bookdrop/review_service_test.go:3503—It("records the correct ids in results")contains threeExpectcalls (results[0].ID, [1].ID, [2].ID). Split into threeItblocks.[MINOR]
internal/bookdrop/review_service_test.go:3524—It("continues updating the remaining ids")contains twoExpectcalls (results[0].Err, results[2].Err). Split into twoItblocks.[MINOR]
internal/bookdrop/review_handler.go:556—logger.InfoinBulkEditMetadataHandleromitstrace_id. All other handlers in this file include"trace_id", middleware.TraceIDFromContext(r.Context())in their log call (see line 467). Suggestion: add it for consistency with the logging standard.REVIEW VERDICT: 0 blocker, 0 major, 4 minor
Visual self-check: AFTER this fix
Bulk Edit Metadata modal (canonical
.sa-modal-header/.sa-modal-footer+.metadata-field/.metadata-field-label/.metadata-field-input+.bbe-form2-col grid):Extract Metadata from Filenames modal (same canonical chrome;
.bep-*kept only for the dynamically-generated preview HTML the controller emits):Both modals now have:
metadata-field-label,color: var(--fg-muted))metadata-field-input).sa-modal-header/.sa-modal-close-btn/.sa-modal-footer)7dc84d8c6739d16af804