fix(middleware): exempt PUT /books/{id}/content from 1MB MaxBytes cap (bookshelf-t582g.4.3) #1246

Merged
zombor merged 2 commits from bd-bookshelf-t582g.4.3 into main 2026-07-27 00:42:35 +00:00
Owner

Summary

  • isUploadPath in internal/middleware/max_bytes.go only exempted POST /books/{id}/files from the 1MB body cap. The replace-content route (PUT /books/{id}/content) was not exempt, so any real book file (>1MB) was rejected with 413 before reaching the handler's own 2GB cap.
  • Fix: add PUT /books/{id}/content to the upload path exemption. The handler already applies http.MaxBytesReader(w, r.Body, maxUploadBodyBytes) + the service's io.LimitReader, so the middleware exemption is safe.
  • Unit test (RED→GREEN): proves a >1MB raw PUT through MaxBytes→MethodOverride chain returns 200.
  • e2e step added to Journey-3: a >1MB PUT through the real app.New() middleware stack reaches the handler and returns 200.

Test plan

  • go test ./internal/middleware/... — 325 specs, all green, 100% coverage
  • go test ./internal/... — all internal packages pass
  • go build -tags e2e ./e2e/... — e2e compiles
  • golangci-lint run ./internal/middleware/... ./e2e/api/... — 0 issues
  • CI: journey-3 e2e step validates the full-stack fix end-to-end

Closes bead bookshelf-t582g.4.3 on merge.

## Summary - `isUploadPath` in `internal/middleware/max_bytes.go` only exempted `POST /books/{id}/files` from the 1MB body cap. The replace-content route (`PUT /books/{id}/content`) was not exempt, so any real book file (>1MB) was rejected with 413 before reaching the handler's own 2GB cap. - Fix: add `PUT /books/{id}/content` to the upload path exemption. The handler already applies `http.MaxBytesReader(w, r.Body, maxUploadBodyBytes)` + the service's `io.LimitReader`, so the middleware exemption is safe. - Unit test (RED→GREEN): proves a >1MB raw PUT through `MaxBytes→MethodOverride` chain returns 200. - e2e step added to Journey-3: a >1MB PUT through the real `app.New()` middleware stack reaches the handler and returns 200. ## Test plan - [x] `go test ./internal/middleware/...` — 325 specs, all green, 100% coverage - [x] `go test ./internal/...` — all internal packages pass - [x] `go build -tags e2e ./e2e/...` — e2e compiles - [x] `golangci-lint run ./internal/middleware/... ./e2e/api/...` — 0 issues - [ ] CI: journey-3 e2e step validates the full-stack fix end-to-end Closes bead bookshelf-t582g.4.3 on merge.
fix(middleware): exempt PUT /books/{id}/content from 1MB MaxBytes cap
Some checks failed
/ JS Unit Tests (pull_request) Successful in 1m55s
/ E2E API (pull_request) Successful in 4m11s
/ Test Race (pull_request) Successful in 4m59s
/ Coverage (pull_request) Successful in 5m41s
/ Integration (pull_request) Successful in 6m26s
/ Lint (pull_request) Successful in 6m30s
/ E2E Browser (pull_request) Failing after 10m33s
8f5a42f77e
The replace-content route streams a raw body directly into the existing
book file, bypassing the middleware's 1MB cap entirely before the handler's
own 2GB cap could apply — causing any real book file (>1MB) to 413.

Fix: extend isUploadPath in max_bytes.go to also exempt
PUT /books/{id}/content (alongside the existing POST .../files exemption).
The handler already applies its own http.MaxBytesReader + service-level
io.LimitReader cap (2 GB), so the middleware exemption is correct.

Tests:
- Unit (body_cap_chain_test.go): RED→GREEN test confirming a >1MB raw PUT
  through the full MaxBytes→MethodOverride chain returns 200, not 413.
- e2e (journey_3_bookdrop_ingest_test.go): full-stack assertion that a >1MB
  PUT through the real wired app.New() stack reaches the handler.

Closes bead bookshelf-t582g.4.3 on merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix(dbtest): bound DROP DATABASE cleanup with 30s timeout to prevent suite hang
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m11s
/ E2E API (pull_request) Successful in 2m33s
/ Test Race (pull_request) Successful in 3m22s
/ Coverage (pull_request) Successful in 3m41s
/ Lint (pull_request) Successful in 4m3s
/ Integration (pull_request) Successful in 4m38s
/ E2E Browser (pull_request) Successful in 6m3s
8517df536c
The NewSuiteDB cleanup func used context.Background() (no timeout) for
DROP DATABASE. When MySQL holds a stuck connection the DROP blocks forever,
causing Ginkgo DeferCleanup to hang and the suite to hit the 10-minute
timeout with all specs complete but cleanup stalled.

Fix: wrap the cleanup DROP in a 30-second context so a hung connection
unblocks within a bounded window rather than running into the suite timeout.
Also use the caller-supplied ctx (which already has a deadline) for the
error-path DROP calls during construction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
zombor left a comment
No description provided.
## Code Review: PR #1246 (bookshelf-t582g.4.3) ### CHECK 1: isUploadPath exemption exactness **PASSED.** The path matching is tight and safe: - `PUT /books/{id}/content` matched by: `method==PUT && startsWith("/books/") && endsWith("/content")` - `POST /books/{id}/files` already matched by: `method==POST && startsWith("/books/") && endsWith("/files")` No over-broadening. All other `/books/*` routes remain capped at 1MB (e.g. `POST /books/{id}/metadata`, `POST /books/bulk/*`, etc.). The suffix matching prevents accidental inclusion of sibling routes. ### CHECK 2: Handler's real cap still applies **PASSED.** `replace_content_handler.go:77` still applies `http.MaxBytesReader(w, r.Body, maxUploadBodyBytes)` where `maxUploadBodyBytes = MaxUploadBytes + 4096` and `MaxUploadBytes = 2 << 30` (2GB). The middleware exemption does not disable the handler's own cap — it simply allows the body to reach the handler instead of being rejected at the middleware layer. ### CHECK 3: >1MB e2e test proves wiring **PASSED.** `journey_3_bookdrop_ingest_test.go` adds a new `It` step that: - Looks up a real file ID from the journey-3 ingest - Sends a PUT with `(1<<20)+512` bytes (~1.5MB) - Asserts `resp.StatusCode == 200` (i.e., middleware did not reject it) - Includes clear justification comment explaining why this cannot be unit-tested The test is properly ordered within the Journey-3 journey and uses the shared `env.DB`, `authClient`, `baseURL` from `BeforeAll`. ### CHECK 4: dbtest cleanup bounding is sound **PASSED.** The 30-second timeout bound on `DROP DATABASE` is a sound flake fix: - Root cause: if MySQL is hung or a connection is held, `DROP DATABASE` blocks indefinitely, which blocks `DeferCleanup` and causes the 10-minute e2e suite timeout. - Fix: wrap the DROP in `context.WithTimeout(..., 30s)` so a hung operation fails fast instead of blocking the suite. - Correctness: the error from DROP timeout is silently swallowed (consistent with the prior pattern `_, _ = rootDB.ExecContext(...)`), which is acceptable for test cleanup — the test harness recreates the template DB on next run anyway. - No regression: test DBs should drop in milliseconds; 30s is generous and masks only genuine infrastructure hangs, not real teardown problems. This is appropriately scoped as part of the same bead (4.3) since both the middleware fix and the e2e test timeout flake are blocking the feature delivery. ### CHECK 5: Conventions & coverage **PASSED.** - The new e2e `It` step is within an `Ordered` journey container (Journey-3), compliant with e2e-policy-check. - Unit test in `body_cap_chain_test.go` adds a new `buildReplaceContentHandler()` helper and a corresponding `It("allows a >1 MB raw PUT to the replace-content route...")`, following the established test pattern. - No new coverage exclusions added. - No style violations. --- **VERDICT:** Zero findings. PR is ready for merge.
Author
Owner

Security Review — PR #1234

Reviewed the diff (removes an inert main:has(.book-show){max-width:none} rule + corrects a stale CSS comment in static/css/main.css).

Verification performed:

  • Confirmed the removed rule is a true no-op — no max-width is set on main anywhere else in main.css, and max-width:none is the CSS initial value, so removal changes nothing rendered.
  • Confirmed max-width is a pure layout property — not contain, overflow, visibility, clip, or any containment/isolation control. No security-relevant affordance is dropped.
  • No CSP / inline-style surface touched (stylesheet file only; no inline styles introduced).
  • Comment fix is factually accurate: .reader-viewer carries max-width:700px; the previously-named .reader-column has no max-width (flex:1). The corrected reference points at the element that actually constrains width.
  • No secrets/PII, no auth/scoping, no injection/SSRF, no fan-out surface — cosmetic CSS-only change.

No findings.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #1234 Reviewed the diff (removes an inert `main:has(.book-show){max-width:none}` rule + corrects a stale CSS comment in `static/css/main.css`). **Verification performed:** - Confirmed the removed rule is a true no-op — no `max-width` is set on `main` anywhere else in `main.css`, and `max-width:none` is the CSS initial value, so removal changes nothing rendered. - Confirmed `max-width` is a pure layout property — not `contain`, `overflow`, visibility, `clip`, or any containment/isolation control. No security-relevant affordance is dropped. - No CSP / inline-style surface touched (stylesheet file only; no inline styles introduced). - Comment fix is factually accurate: `.reader-viewer` carries `max-width:700px`; the previously-named `.reader-column` has no max-width (`flex:1`). The corrected reference points at the element that actually constrains width. - No secrets/PII, no auth/scoping, no injection/SSRF, no fan-out surface — cosmetic CSS-only change. No findings. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Author
Owner

Security Review — PR #1246 (exempt PUT /books/{id}/content from 1 MB MaxBytes cap)

Scope: internal/middleware/max_bytes.go exemption + wiring, internal/books/replace_content_{handler,service,store}.go, test additions.

Adversarial focus verification

(1) Exemption is NARROW — confirmed. isUploadPath now returns true for PUT + prefix /books/ + suffix /content (in addition to the existing POST .../files). Enumerated every non-GET route ending in /content under /books/: the only match is the single registered route PUT /books/{id}/content (internal/books/routes.go:175). The other /content route is GET .../file/{fileID}/content — GET is never body-capped and doesn't match the PUT check. No other PUT/POST/PATCH/DELETE route ends in /content, so an attacker cannot steer a huge body to a different endpoint via this suffix. Exemption is path-based, not Content-Type-based, so a forged multipart/form-data header on any other route is still capped.

(1b) Method-override cannot widen the exemption — confirmed. Chain order is MaxBytes -> CSRF -> MethodOverride -> mux (internal/app/app.go:940-943). MaxBytes evaluates isUploadPath while the method is still the wire method (POST), before _method=PUT rewriting; a forged POST ...?_method=PUT to a non-/content path is capped at 1 MB. No bypass.

(2) Exempted route still has a REAL upper bound — confirmed. Not unbounded. Two independent layers cap it at 2 GB: the handler wraps the body with http.MaxBytesReader(w, r.Body, maxUploadBodyBytes) where maxUploadBodyBytes = MaxUploadBytes + 4096 and MaxUploadBytes = 2 << 30 (replace_content_handler.go:77, upload_service.go:34), and the service independently enforces it with io.LimitReader(src, limit+1) + a written-count check returning ErrFileTooLarge (replace_content_service.go:150-163). Streamed via temp+rename (no full buffering in memory) -> no memory/disk DoS.

(3) Auth/ownership gating — confirmed. Route is gated by g.EditMetadata (PermissionEditMetadata) (routes.go:175). userID is session-sourced (userIDFromRequest = d.ExtractUser(r).ID, wire.go:640), never request-supplied. The file lookup GetBookFileForReplace scopes by user via JOIN user_library_mapping ulm ON ulm.library_id = b.library_id AND ulm.user_id = ? (replace_content_store.go), so a user cannot overwrite a file in a library they can't access; a miss returns sql.ErrNoRows -> ErrNotFound (404, no existence leak). Path-containment check rejects file_sub_path traversal outside the library root.

Other notes

  • Test additions are black-box (package middleware_test, package api_test); the e2e spec carries the required cannot-test-at-unit-level justification and asserts the exemption end-to-end through app.New().
  • internal/dbtest/dbtest.go change (bounded 30s drop-timeout + ctx propagation on cleanup) is a test-harness resilience fix, unrelated to the exemption; no security impact.

No security findings.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Review — PR #1246 (exempt PUT /books/{id}/content from 1 MB MaxBytes cap) **Scope:** `internal/middleware/max_bytes.go` exemption + wiring, `internal/books/replace_content_{handler,service,store}.go`, test additions. ### Adversarial focus verification **(1) Exemption is NARROW — confirmed.** `isUploadPath` now returns true for `PUT` + prefix `/books/` + suffix `/content` (in addition to the existing `POST .../files`). Enumerated every non-GET route ending in `/content` under `/books/`: the only match is the single registered route `PUT /books/{id}/content` (`internal/books/routes.go:175`). The other `/content` route is `GET .../file/{fileID}/content` — GET is never body-capped and doesn't match the PUT check. No other PUT/POST/PATCH/DELETE route ends in `/content`, so an attacker cannot steer a huge body to a different endpoint via this suffix. Exemption is path-based, not Content-Type-based, so a forged `multipart/form-data` header on any other route is still capped. **(1b) Method-override cannot widen the exemption — confirmed.** Chain order is `MaxBytes -> CSRF -> MethodOverride -> mux` (`internal/app/app.go:940-943`). MaxBytes evaluates `isUploadPath` while the method is still the wire method (POST), before `_method=PUT` rewriting; a forged `POST ...?_method=PUT` to a non-`/content` path is capped at 1 MB. No bypass. **(2) Exempted route still has a REAL upper bound — confirmed.** Not unbounded. Two independent layers cap it at 2 GB: the handler wraps the body with `http.MaxBytesReader(w, r.Body, maxUploadBodyBytes)` where `maxUploadBodyBytes = MaxUploadBytes + 4096` and `MaxUploadBytes = 2 << 30` (`replace_content_handler.go:77`, `upload_service.go:34`), and the service independently enforces it with `io.LimitReader(src, limit+1)` + a written-count check returning `ErrFileTooLarge` (`replace_content_service.go:150-163`). Streamed via temp+rename (no full buffering in memory) -> no memory/disk DoS. **(3) Auth/ownership gating — confirmed.** Route is gated by `g.EditMetadata` (PermissionEditMetadata) (`routes.go:175`). `userID` is session-sourced (`userIDFromRequest = d.ExtractUser(r).ID`, `wire.go:640`), never request-supplied. The file lookup `GetBookFileForReplace` scopes by user via `JOIN user_library_mapping ulm ON ulm.library_id = b.library_id AND ulm.user_id = ?` (`replace_content_store.go`), so a user cannot overwrite a file in a library they can't access; a miss returns `sql.ErrNoRows -> ErrNotFound` (404, no existence leak). Path-containment check rejects `file_sub_path` traversal outside the library root. ### Other notes - Test additions are black-box (`package middleware_test`, `package api_test`); the e2e spec carries the required cannot-test-at-unit-level justification and asserts the exemption end-to-end through `app.New()`. - `internal/dbtest/dbtest.go` change (bounded 30s drop-timeout + `ctx` propagation on cleanup) is a test-harness resilience fix, unrelated to the exemption; no security impact. No security findings. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
zombor force-pushed bd-bookshelf-t582g.4.3 from 8517df536c
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m11s
/ E2E API (pull_request) Successful in 2m33s
/ Test Race (pull_request) Successful in 3m22s
/ Coverage (pull_request) Successful in 3m41s
/ Lint (pull_request) Successful in 4m3s
/ Integration (pull_request) Successful in 4m38s
/ E2E Browser (pull_request) Successful in 6m3s
to a3c007688c
All checks were successful
/ Test Race (pull_request) Successful in 4m21s
/ Coverage (pull_request) Successful in 4m58s
/ E2E API (pull_request) Successful in 2m15s
/ JS Unit Tests (pull_request) Successful in 1m23s
/ Lint (pull_request) Successful in 5m49s
/ Integration (pull_request) Successful in 3m48s
/ E2E Browser (pull_request) Successful in 6m5s
2026-07-27 00:31:52 +00:00
Compare
zombor merged commit a3d2d53985 into main 2026-07-27 00:42:35 +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!1246
No description provided.