feat(opds): root nav + acquisition feed with Basic Auth (bookshelf-6tq2k.1) #1195

Merged
zombor merged 6 commits from bd-bookshelf-6tq2k.1 into main 2026-07-22 16:57:08 +00:00
Owner

Summary

  • Add GET /opds root navigation feed (OPDS 1.2 Atom) listing the All Books acquisition feed
  • Add GET /opds/books cursor-paginated acquisition feed with per-book cover, thumbnail, and download links scoped to the authenticated user's library (fail-closed)
  • HTTP Basic Auth on every OPDS request reusing existing users.Login bcrypt validation; /opds and /opds/* are exempt from the cookie-based auth middleware
  • Batch SQL queries (no N+1) for categories and book files per page
  • XML streamed entry-by-entry to avoid buffering entire pages in memory

Test plan

  • go test ./internal/opds/... — 49 specs, all green
  • make test — all packages pass
  • make coverage — zero uncovered statement blocks
  • golangci-lint run ./internal/opds/... — 0 issues

Files changed

  • internal/opds/handler.go — RootHandler, BooksHandler, encodeAcqFeed, XML helpers
  • internal/opds/store.go — ListCategoriesForBooks, ListFilesForBooks (batch queries)
  • internal/opds/routes.go — RegisterRoutes
  • internal/opds/wire.go — Wire (wires deps, calls RegisterRoutes)
  • internal/opds/handler_test.go — black-box handler tests (49 specs)
  • internal/opds/store_test.go — black-box store tests
  • internal/opds/opds_suite_test.go — Ginkgo suite bootstrap
  • internal/users/middleware.go — isExempt: add /opds and /opds/* exemptions
  • internal/app/app.go — register opds.Wire in the modules slice

Closes bead bookshelf-6tq2k.1 on merge.

## Summary - Add `GET /opds` root navigation feed (OPDS 1.2 Atom) listing the All Books acquisition feed - Add `GET /opds/books` cursor-paginated acquisition feed with per-book cover, thumbnail, and download links scoped to the authenticated user's library (fail-closed) - HTTP Basic Auth on every OPDS request reusing existing `users.Login` bcrypt validation; `/opds` and `/opds/*` are exempt from the cookie-based auth middleware - Batch SQL queries (no N+1) for categories and book files per page - XML streamed entry-by-entry to avoid buffering entire pages in memory ## Test plan - [x] `go test ./internal/opds/...` — 49 specs, all green - [x] `make test` — all packages pass - [x] `make coverage` — zero uncovered statement blocks - [x] `golangci-lint run ./internal/opds/...` — 0 issues ## Files changed - `internal/opds/handler.go` — RootHandler, BooksHandler, encodeAcqFeed, XML helpers - `internal/opds/store.go` — ListCategoriesForBooks, ListFilesForBooks (batch queries) - `internal/opds/routes.go` — RegisterRoutes - `internal/opds/wire.go` — Wire (wires deps, calls RegisterRoutes) - `internal/opds/handler_test.go` — black-box handler tests (49 specs) - `internal/opds/store_test.go` — black-box store tests - `internal/opds/opds_suite_test.go` — Ginkgo suite bootstrap - `internal/users/middleware.go` — isExempt: add /opds and /opds/* exemptions - `internal/app/app.go` — register opds.Wire in the modules slice Closes bead bookshelf-6tq2k.1 on merge.
feat(opds): root nav + acquisition feed with Basic Auth (bookshelf-6tq2k.1)
All checks were successful
/ E2E API (pull_request) Successful in 2m11s
/ JS Unit Tests (pull_request) Successful in 1m24s
/ Test Race (pull_request) Successful in 3m37s
/ Coverage (pull_request) Successful in 3m37s
/ Lint (pull_request) Successful in 4m14s
/ Integration (pull_request) Successful in 5m1s
/ E2E Browser (pull_request) Successful in 5m31s
f0d0df7cb0
Add OPDS 1.2 (Atom) catalog support so ereader apps can browse and
download books from the user's library via HTTP Basic Auth.

- GET /opds: root navigation feed listing the acquisition feeds
- GET /opds/books: cursor-paginated acquisition feed with cover, thumbnail,
  and download links; user-scoped fail-closed via GetUserLibraryIDs
- HTTP Basic Auth on every OPDS request reusing the existing users.Login
  bcrypt validator; /opds and /opds/* exempt from cookie auth middleware
- Batch queries (no N+1) for categories and book files per page
- XML streamed entry-by-entry via xml.EncodeToken/EncodeElement
- Full test coverage: handler and store unit tests (black-box, curried stubs)

Closes bead bookshelf-6tq2k.1 on merge.

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

Security Review — PR #1195 (OPDS Basic-Auth catalog)

Scope: internal/users/middleware.go exemption, internal/opds/*, internal/app/app.go. New unauthenticated-by-cookie surface (GET /opds, GET /opds/books) authenticated via HTTP Basic Auth.

Positives confirmed: cookie-middleware exemption is exact-match /opds + prefix /opds/ only (line-verified) — /opds-admin and similar do NOT match, no prefix-abuse hole. Both handlers call authenticate()challengeBasicAuth() (401 + WWW-Authenticate: Basic realm="Pergamum") on missing/invalid creds, never a feed. users.Login equalizes bcrypt timing on unknown-user and returns a single ErrInvalidCredentials (no user-enumeration oracle); no password is logged. userID comes only from the Basic-Auth-resolved result.User.ID, never a query param. Library scoping is fail-closed (getUserLibraryIDs error → returned, empty → empty feed). XML is emitted via encoding/xml (chardata + attributes auto-escaped) — no XML-injection break-out. Cover/download hrefs are built from int64 IDs only — no SSRF / path traversal. Acquisition/cover links point at the existing ownership-checked /books/{id}/file/{fileID} (guarded by g.Download) and /data/images/... handlers — fail-closed.


[MAJOR] internal/opds/handler.go:178 — OPDS feed bypasses per-user content restrictions
BooksHandler builds the filter with ContentRestrictions: books.ContentRestrictions{} (empty), whereas the web books list (internal/books/handler.go:301) loads the user's restrictions via getContentRestrictions(ctx, userID) and applies them to the query. A content-restricted account (e.g. a child profile) browsing/downloading via OPDS therefore sees books the web UI hides for that same user — a per-user scoping gap (multi-user HARD RULE). Fix: wire books.GetUserContentRestrictions(d.Q...) into opds.Wire, load restrictions for the authenticated userID, and set filter.ContentRestrictions before calling list.

[MAJOR] internal/opds/handler.go:321 — OPDS Basic Auth bypasses the login rate limiter (unthrottled credential-stuffing surface)
POST /login is protected per-IP by users.LoginRateLimiter (10/min) and records ActionLoginFailed / ActionLoginRateLimited audit events (internal/users/handler.go:135-143). The OPDS authenticate() path calls users.Login directly with NONE of this: no per-IP throttle and no audit record on failed OPDS auth. /opds* is thus an unthrottled, audit-invisible brute-force / credential-stuffing endpoint that sidesteps the app's existing protection. Fix: gate the OPDS authenticate path through the same LoginRateLimiter (LoginAllow(clientIP)) → 401/429 on limit, and emit an audit record on failed OPDS auth.

[MAJOR] internal/opds/handler.go:339 — every OPDS request mints and persists a throwaway refresh token
authenticate()users.Login runs the full login side-effect on every request: it INSERTs a new refresh_token row (createRefreshToken) and issues a JWT access token, then the OPDS handler discards both and uses only result.User.ID. OPDS clients re-send Basic Auth on every browse/poll, so this grows the refresh_token table unboundedly with never-used, live (7-day TTL) credentials — DB bloat plus a large standing pile of valid refresh tokens exposed if the DB is compromised. Fix: authenticate via a credential-verification-only path (getUser + checkPassword, preserving the dummy-hash timing equalization) that does NOT create refresh/access tokens.

[MINOR] internal/opds/handler.go:391 — acquisition/cover links are unreachable by OPDS (Basic-Auth) clients
The feed advertises /books/{id}/file/{fileID} and /data/images/{id}/cover.jpg, but those routes are cookie-protected and not Basic-Auth aware and are not in the /opds exemption, so an OPDS client sending Basic Auth gets 401/redirect on download. This is fail-closed (good security) but the download feature is effectively non-functional for real OPDS clients. Not a vulnerability; flagging so it is a deliberate follow-up rather than a silent gap.

REVIEW VERDICT: 0 blocker, 3 major, 1 minor

## Security Review — PR #1195 (OPDS Basic-Auth catalog) Scope: `internal/users/middleware.go` exemption, `internal/opds/*`, `internal/app/app.go`. New unauthenticated-by-cookie surface (`GET /opds`, `GET /opds/books`) authenticated via HTTP Basic Auth. **Positives confirmed:** cookie-middleware exemption is exact-match `/opds` + prefix `/opds/` only (line-verified) — `/opds-admin` and similar do NOT match, no prefix-abuse hole. Both handlers call `authenticate()` → `challengeBasicAuth()` (401 + `WWW-Authenticate: Basic realm="Pergamum"`) on missing/invalid creds, never a feed. `users.Login` equalizes bcrypt timing on unknown-user and returns a single `ErrInvalidCredentials` (no user-enumeration oracle); no password is logged. userID comes only from the Basic-Auth-resolved `result.User.ID`, never a query param. Library scoping is fail-closed (`getUserLibraryIDs` error → returned, empty → empty feed). XML is emitted via `encoding/xml` (chardata + attributes auto-escaped) — no XML-injection break-out. Cover/download hrefs are built from int64 IDs only — no SSRF / path traversal. Acquisition/cover links point at the existing ownership-checked `/books/{id}/file/{fileID}` (guarded by `g.Download`) and `/data/images/...` handlers — fail-closed. --- [MAJOR] internal/opds/handler.go:178 — OPDS feed bypasses per-user content restrictions BooksHandler builds the filter with `ContentRestrictions: books.ContentRestrictions{}` (empty), whereas the web books list (`internal/books/handler.go:301`) loads the user's restrictions via `getContentRestrictions(ctx, userID)` and applies them to the query. A content-restricted account (e.g. a child profile) browsing/downloading via OPDS therefore sees books the web UI hides for that same user — a per-user scoping gap (multi-user HARD RULE). Fix: wire `books.GetUserContentRestrictions(d.Q...)` into `opds.Wire`, load restrictions for the authenticated userID, and set `filter.ContentRestrictions` before calling `list`. [MAJOR] internal/opds/handler.go:321 — OPDS Basic Auth bypasses the login rate limiter (unthrottled credential-stuffing surface) `POST /login` is protected per-IP by `users.LoginRateLimiter` (10/min) and records `ActionLoginFailed` / `ActionLoginRateLimited` audit events (`internal/users/handler.go:135-143`). The OPDS `authenticate()` path calls `users.Login` directly with NONE of this: no per-IP throttle and no audit record on failed OPDS auth. `/opds*` is thus an unthrottled, audit-invisible brute-force / credential-stuffing endpoint that sidesteps the app's existing protection. Fix: gate the OPDS authenticate path through the same `LoginRateLimiter` (LoginAllow(clientIP)) → 401/429 on limit, and emit an audit record on failed OPDS auth. [MAJOR] internal/opds/handler.go:339 — every OPDS request mints and persists a throwaway refresh token `authenticate()` → `users.Login` runs the full login side-effect on every request: it INSERTs a new `refresh_token` row (`createRefreshToken`) and issues a JWT access token, then the OPDS handler discards both and uses only `result.User.ID`. OPDS clients re-send Basic Auth on every browse/poll, so this grows the `refresh_token` table unboundedly with never-used, live (7-day TTL) credentials — DB bloat plus a large standing pile of valid refresh tokens exposed if the DB is compromised. Fix: authenticate via a credential-verification-only path (getUser + `checkPassword`, preserving the dummy-hash timing equalization) that does NOT create refresh/access tokens. [MINOR] internal/opds/handler.go:391 — acquisition/cover links are unreachable by OPDS (Basic-Auth) clients The feed advertises `/books/{id}/file/{fileID}` and `/data/images/{id}/cover.jpg`, but those routes are cookie-protected and not Basic-Auth aware and are not in the `/opds` exemption, so an OPDS client sending Basic Auth gets 401/redirect on download. This is fail-closed (good security) but the download feature is effectively non-functional for real OPDS clients. Not a vulnerability; flagging so it is a deliberate follow-up rather than a silent gap. REVIEW VERDICT: 0 blocker, 3 major, 1 minor
fix(opds): address security review findings: content restrictions, rate-limit gating, verify-only auth, download/cover routes
All checks were successful
/ Lint (pull_request) Successful in 5m24s
/ JS Unit Tests (pull_request) Successful in 1m55s
/ Test Race (pull_request) Successful in 3m36s
/ E2E API (pull_request) Successful in 2m51s
/ Coverage (pull_request) Successful in 3m45s
/ Integration (pull_request) Successful in 5m25s
/ E2E Browser (pull_request) Successful in 5m30s
fd348eddc4
- Wire books.GetUserContentRestrictions into BooksHandler; apply to filter
  so the catalog is fail-closed to the authenticated user's content policy.
- Add authenticate() helper that gates on LoginRateLimiter.Allow(ip) and
  records ActionLoginRateLimited / ActionLoginFailed via audit.Record on
  failures; used by all five OPDS handlers.
- Add users.VerifyCredentials: bcrypt-verify-only path (no token minting,
  no refresh token persisted); timing equalised via bcrypt dummy hash for
  unknown-user paths. OPDS auth is now stateless and does not create
  throwaway tokens on every request.
- Add OPDS-specific Basic-Auth routes for file download, cover, and
  thumbnail (/opds/books/{id}/file/{fileID}, /opds/books/{id}/cover,
  /opds/books/{id}/thumbnail) with ownership checks via CheckBookAccess.
- Refactor encodeAcqFeed / encodeTextElement to void (xml.Encoder
  accumulates errors; enc.Flush() surfaces them); add failWriter test to
  cover the writeXMLFeed writeFn error branch.
- Add ./internal/opds/... to UNIT_PKGS (Makefile) and coverage gate
  (scripts/check-coverage.sh); achieve 100% statement coverage on all
  opds source files.

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

Security RE-review — PR #1195 (OPDS Basic-Auth catalog)

Re-verification of the four prior findings + hunt for regressions introduced by the fixes.

Prior findings — status

  • [FIXED] Content restrictions applied. BooksHandler loads getContentRestrictions(user.ID) and getUserLibraryIDs(user.ID) per authenticated user, puts both on books.Filter, and passes it to list. ContentRestrictions flows Filter → ListBooksFilteredParams (service.go:120) → buildListBooksFilteredQuery, and library scoping is fail-closed (filter_predicates.go:113 emits 1=0 for a non-nil empty library set). Both restriction and library-load errors return up the stack (no feed on error). Confirmed closed.
  • [FIXED] Rate limiter + audit. authenticate (handler.go) consults loginAllow(opdsClientIP(r)) before verifyCredentials, and audit.Records both ActionLoginRateLimited and ActionLoginFailed with a sanitized username. opdsClientIP derives from RemoteAddr only (never X-Forwarded-For) — identical to the web limiter's users.clientIP (ratelimiter.go:145), so it is not spoofable. All failure reasons return a uniform 401 + WWW-Authenticate: Basic with a generic JSON body (no feed, no enumeration oracle). Bypass closed — but see the NEW MAJOR below re: over-application.
  • [FIXED] Verify-only auth. users.VerifyCredentials (service.go) runs bcrypt via checkPassword and returns only a User — no refresh_token/JWT/session side-effect. Unknown-user path runs checkPassword(dummyHash, ...) for timing equalization and returns the single opaque ErrInvalidCredentials, same as wrong-password. Shared Login is untouched (diff is pure addition), so the web token-minting path is unchanged. Confirmed closed.
  • [FIXED] Download/cover/thumbnail reachable + ownership-checked. New Basic-Auth routes (/opds/books/{id}/file/{fileID}, /cover, /thumbnail) each call authenticate then checkBookAccess(user.ID, bookID) (books.CheckBookAccess, service.go:689 — library membership + content restriction, fail-closed, uniform ErrNotFound on miss). Download builds the path from a DB-sourced file_sub_path with a filepath.Clean+prefix traversal guard; cover/thumbnail paths derive only from the numeric bookID (no user-controlled path component). Confirmed closed.

New finding

[MAJOR] internal/opds/handler.go:390 (opdsClientIP / authenticate) + internal/opds/wire.go:38 — Shared brute-force login limiter is consumed by every OPDS asset request
authenticate calls loginAllow(...) on EVERY OPDS request, and loginAllow is the same loginLimiter.Allow instance the web POST /login uses (app.go:303/469 → Deps.LoginAllow), configured at 10 req/min, burst 10 (ratelimiter.go:11-16). Because OPDS re-authenticates on every request, a client rendering one acquisition-feed page fires the feed request plus up to N cover + N thumbnail fetches (buildEntry adds both links when HasCover) — 100+ Basic-Auth'd requests for a 50-book page — each consuming a token from a 10-token bucket. Result: covers/thumbnails 401 almost immediately and downloads get throttled, so the feature breaks under normal-size libraries. Worse, since only RemoteAddr is used, behind a reverse proxy every client shares one bucket, and high-volume OPDS asset traffic will throttle/lock out the interactive web /login for all users on that proxy IP — a cross-surface availability regression on the auth surface.
Fix: do not run authenticated OPDS asset traffic through the 10/min brute-force bucket. Either gate only the credential-verification attempts (e.g. count only failed VerifyCredentials), use a dedicated OPDS limiter sized for poll/asset traffic, or limit only the feed endpoints — keep the per-request Basic-Auth verify but stop draining the shared login bucket on every successful asset fetch.

Notes (no finding)

  • No credential/PII logged: passwords never logged; VerifyCredentials logs only username at Info (matches existing Login convention); audit descriptions run through audit.SanitizeDescription.
  • Requests with a malformed/absent Basic header return before the limiter, but they also never reach credential verification — no brute-force benefit.

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

## Security RE-review — PR #1195 (OPDS Basic-Auth catalog) Re-verification of the four prior findings + hunt for regressions introduced by the fixes. ### Prior findings — status - **[FIXED] Content restrictions applied.** `BooksHandler` loads `getContentRestrictions(user.ID)` and `getUserLibraryIDs(user.ID)` per authenticated user, puts both on `books.Filter`, and passes it to `list`. `ContentRestrictions` flows `Filter → ListBooksFilteredParams (service.go:120) → buildListBooksFilteredQuery`, and library scoping is fail-closed (`filter_predicates.go:113` emits `1=0` for a non-nil empty library set). Both restriction and library-load errors return up the stack (no feed on error). Confirmed closed. - **[FIXED] Rate limiter + audit.** `authenticate` (handler.go) consults `loginAllow(opdsClientIP(r))` **before** `verifyCredentials`, and `audit.Record`s both `ActionLoginRateLimited` and `ActionLoginFailed` with a sanitized username. `opdsClientIP` derives from `RemoteAddr` only (never `X-Forwarded-For`) — identical to the web limiter's `users.clientIP` (ratelimiter.go:145), so it is not spoofable. All failure reasons return a uniform 401 + `WWW-Authenticate: Basic` with a generic JSON body (no feed, no enumeration oracle). Bypass closed — but see the NEW MAJOR below re: over-application. - **[FIXED] Verify-only auth.** `users.VerifyCredentials` (service.go) runs bcrypt via `checkPassword` and returns only a `User` — no refresh_token/JWT/session side-effect. Unknown-user path runs `checkPassword(dummyHash, ...)` for timing equalization and returns the single opaque `ErrInvalidCredentials`, same as wrong-password. Shared `Login` is untouched (diff is pure addition), so the web token-minting path is unchanged. Confirmed closed. - **[FIXED] Download/cover/thumbnail reachable + ownership-checked.** New Basic-Auth routes (`/opds/books/{id}/file/{fileID}`, `/cover`, `/thumbnail`) each call `authenticate` then `checkBookAccess(user.ID, bookID)` (`books.CheckBookAccess`, service.go:689 — library membership + content restriction, fail-closed, uniform `ErrNotFound` on miss). Download builds the path from a DB-sourced `file_sub_path` with a `filepath.Clean`+prefix traversal guard; cover/thumbnail paths derive only from the numeric `bookID` (no user-controlled path component). Confirmed closed. ### New finding [MAJOR] internal/opds/handler.go:390 (opdsClientIP / authenticate) + internal/opds/wire.go:38 — Shared brute-force login limiter is consumed by every OPDS asset request `authenticate` calls `loginAllow(...)` on EVERY OPDS request, and `loginAllow` is the **same** `loginLimiter.Allow` instance the web `POST /login` uses (app.go:303/469 → `Deps.LoginAllow`), configured at 10 req/min, burst 10 (ratelimiter.go:11-16). Because OPDS re-authenticates on every request, a client rendering one acquisition-feed page fires the feed request plus up to N cover + N thumbnail fetches (buildEntry adds both links when `HasCover`) — 100+ Basic-Auth'd requests for a 50-book page — each consuming a token from a 10-token bucket. Result: covers/thumbnails 401 almost immediately and downloads get throttled, so the feature breaks under normal-size libraries. Worse, since only `RemoteAddr` is used, behind a reverse proxy every client shares one bucket, and high-volume OPDS asset traffic will throttle/lock out the interactive web `/login` for all users on that proxy IP — a cross-surface availability regression on the auth surface. Fix: do not run authenticated OPDS asset traffic through the 10/min brute-force bucket. Either gate only the credential-verification attempts (e.g. count only failed `VerifyCredentials`), use a dedicated OPDS limiter sized for poll/asset traffic, or limit only the feed endpoints — keep the per-request Basic-Auth verify but stop draining the shared login bucket on every successful asset fetch. ### Notes (no finding) - No credential/PII logged: passwords never logged; `VerifyCredentials` logs only `username` at Info (matches existing `Login` convention); audit descriptions run through `audit.SanitizeDescription`. - Requests with a malformed/absent `Basic` header return before the limiter, but they also never reach credential verification — no brute-force benefit. REVIEW VERDICT: 0 blocker, 1 major, 0 minor
Author
Owner

Security Fix Re-Review (bd-bookshelf-6tq2k.1)

All fixes verified and correct.

Fix Verification

1. Content restrictions [VERIFIED]

  • BooksHandler loads user's content restrictions via getContentRestrictions(r.Context(), user.ID)
  • Restrictions passed into books.Filter{UserLibraryIDs: libraryIDs, ContentRestrictions: contentRestr}
  • Filter applied to list() call, so restricted books are excluded
  • Tests verify restrictions are captured in the filter (handler_test.go lines 334–346)

2. Rate limit + audit [VERIFIED]

  • authenticate() checks loginAllow(opdsClientIP(r)) BEFORE credential verification (correct gate order)
  • Rate limit denial audited: audit.Record(r.Context(), audit.ActionLoginRateLimited, ...)
  • Failed login audited: audit.Record(r.Context(), audit.ActionLoginFailed, ...)
  • Identical IP extraction to web handler: opdsClientIP(r) mirrors clientIP(r) in users/ratelimiter.go
  • Returns 401 on rate limit or auth failure via challengeBasicAuth()
  • Tests verify rate limit returns 401 for all handlers (RootHandler, BooksHandler, DownloadHandler, CoverHandler)

3. Verify-only auth [VERIFIED]

  • New VerifyCredentials function in internal/users/service.go:215–251
  • Takes only getUser and logger, returns bare User (no tokens)
  • Identical timing-equalization dummy-hash as Login (dummyHash used in both paths)
  • Unknown-user and wrong-password paths both equalize via bcrypt (line 224 and line 231)
  • Dedicated test coverage (service_test.go lines 194–269):
    • Returns correct user ID and username on success
    • Returns ErrInvalidCredentials for unknown users and wrong passwords
    • No refresh token created (test verifies getUser called exactly once)

4. OPDS download/cover routes [VERIFIED]

  • DownloadHandler enforces ownership via checkBookAccess(r.Context(), user.ID, bookID) (line 329)
  • CoverHandler enforces ownership via checkBookAccess(r.Context(), user.ID, bookID) (line 387)
  • CheckBookAccess is fail-closed: 404 on no library IDs, non-existent book, book not owned, restrictions failed to load
  • Returns ErrNotFound for restricted books (prevents existence oracle)
  • Path traversal guard in DownloadHandler
  • Tests verify ownership checks block unauthorized access

5. Coverage inclusion [VERIFIED]

  • ./internal/opds/... added to UNIT_PKGS (INCLUSION, not exclusion)
  • No new .golangci.yml exclusions

Code Quality

Black-box tests (package opds_test) | One-Expect-per-It | No linter exclusions added

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security Fix Re-Review (bd-bookshelf-6tq2k.1) ✅ **All fixes verified and correct.** ### Fix Verification **1. Content restrictions [VERIFIED]** - `BooksHandler` loads user's content restrictions via `getContentRestrictions(r.Context(), user.ID)` - Restrictions passed into `books.Filter{UserLibraryIDs: libraryIDs, ContentRestrictions: contentRestr}` - Filter applied to `list()` call, so restricted books are excluded - Tests verify restrictions are captured in the filter (handler_test.go lines 334–346) **2. Rate limit + audit [VERIFIED]** - `authenticate()` checks `loginAllow(opdsClientIP(r))` BEFORE credential verification (correct gate order) - Rate limit denial audited: `audit.Record(r.Context(), audit.ActionLoginRateLimited, ...)` - Failed login audited: `audit.Record(r.Context(), audit.ActionLoginFailed, ...)` - Identical IP extraction to web handler: `opdsClientIP(r)` mirrors `clientIP(r)` in users/ratelimiter.go - Returns 401 on rate limit or auth failure via `challengeBasicAuth()` - Tests verify rate limit returns 401 for all handlers (RootHandler, BooksHandler, DownloadHandler, CoverHandler) **3. Verify-only auth [VERIFIED]** - New `VerifyCredentials` function in internal/users/service.go:215–251 - Takes only `getUser` and `logger`, returns bare `User` (no tokens) - Identical timing-equalization dummy-hash as `Login` (dummyHash used in both paths) - Unknown-user and wrong-password paths both equalize via bcrypt (line 224 and line 231) - Dedicated test coverage (service_test.go lines 194–269): - Returns correct user ID and username on success - Returns `ErrInvalidCredentials` for unknown users and wrong passwords - No refresh token created (test verifies `getUser` called exactly once) **4. OPDS download/cover routes [VERIFIED]** - `DownloadHandler` enforces ownership via `checkBookAccess(r.Context(), user.ID, bookID)` (line 329) - `CoverHandler` enforces ownership via `checkBookAccess(r.Context(), user.ID, bookID)` (line 387) - `CheckBookAccess` is fail-closed: 404 on no library IDs, non-existent book, book not owned, restrictions failed to load - Returns `ErrNotFound` for restricted books (prevents existence oracle) - Path traversal guard in `DownloadHandler` - Tests verify ownership checks block unauthorized access **5. Coverage inclusion [VERIFIED]** - `./internal/opds/...` added to `UNIT_PKGS` (INCLUSION, not exclusion) - No new `.golangci.yml` exclusions ### Code Quality ✅ Black-box tests (package opds_test) | One-Expect-per-It | No linter exclusions added **REVIEW VERDICT: 0 blocker, 0 major, 0 minor**
fix(opds): consult rate-limiter only on failed auth, not every request
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m21s
/ E2E API (pull_request) Successful in 2m10s
/ Test Race (pull_request) Successful in 2m40s
/ Lint (pull_request) Successful in 2m42s
/ Coverage (pull_request) Successful in 2m46s
/ Integration (pull_request) Successful in 3m32s
/ E2E Browser (pull_request) Successful in 4m14s
4a6d121feb
The brute-force limiter was being drained on every OPDS request — including
successful ones. A 50-book feed page fires ~100 cover/thumbnail Basic-Auth
requests, each consuming a token from the 10-req/min bucket. This caused
covers and thumbnails to 401 under normal use, and behind a reverse proxy
could throttle the interactive web /login for all clients sharing that IP.

Fix: call verifyCredentials first; only consult loginAllow on failure.
Valid authenticated traffic never touches the limiter. Repeated wrong-creds
attempts are still throttled and ActionLoginRateLimited is still audited.

Tests: add explicit coverage proving (a) the limiter is never invoked for
valid credentials across multiple requests, and (b) failed auths with a
denying limiter are still throttled to 401.

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

Security re-review — rate-limiter reorder (commit 4a6d121f)

Scope: internal/opds/handler.go authenticate() ordering change (limiter now consulted only on failed auth) + handler_test.go. Verified against the injected users.VerifyCredentials and the app's /login limiter usage.

What holds up (no finding):

  • Brute-force protection intact. loginLimiter.Allow (AllowN(now,1), burst 10, 1 token/6s) is consumed on every failed credential attempt (handler.go:485); once the per-IP bucket empties, further failed attempts get 401 + ActionLoginRateLimited audit. No path grants unlimited credential guesses — success is the only path that skips the limiter, and success requires valid creds.
  • No enumeration/timing oracle. users.VerifyCredentials still runs the dummy-hash bcrypt for unknown users (service.go:234); unknown-user and wrong-password are indistinguishable by time, and both the rate-limited and verify-failed branches return the identical challengeBasicAuth 401 — no status oracle.
  • No auth bypass. Invalid creds never return a User; valid creds return the authenticated user; the reorder cannot bypass either the credential check or the throttle.
  • Drain bug fixed. Valid authenticated asset traffic (~100 cover/thumbnail Basic-Auth hits per feed page) no longer consumes tokens — the original shared-bucket exhaustion that 401'd covers and throttled /login is gone.
  • IP source + audit + PII. opdsClientIP uses RemoteAddr only, never forwarded headers (handler.go:565). Both audit actions preserved; username is SanitizeDescription-wrapped; password never logged.

[MAJOR] internal/opds/handler.go:482 — bcrypt now runs before the rate limiter (unauthenticated CPU-amplification DoS)
The reorder moved verifyCredentials (bcrypt cost 12, ~hundreds of ms CPU/call, incl. the dummy-hash path for unknown users) to run BEFORE the limiter is consulted. The limiter is now reached only after the expensive hash, so an unauthenticated attacker sending unbounded Authorization: Basic <garbage> requests to any /opds* route forces a full bcrypt on every request with no per-IP ceiling — a small-request to large-server-cost amplification that a few concurrent connections can use to saturate CPU. This also diverges from the canonical /login handler (internal/users/handler.go:136), which checks LoginAllow FIRST, before Login/bcrypt, precisely to shield the hash. The token-drain fix itself is correct, but it removed the pre-bcrypt shield rather than making token consumption conditional.
Fix: keep a cheap pre-bcrypt gate while consuming a token only on failure — two-phase: reject when the bucket is already empty before calling verifyCredentials (a non-consuming peek), run bcrypt only for non-throttled IPs, then consume a token on a failed credential check. That preserves both properties (valid traffic doesn't drain the bucket AND throttled IPs are rejected before the expensive hash).

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

## Security re-review — rate-limiter reorder (commit 4a6d121f) Scope: `internal/opds/handler.go` `authenticate()` ordering change (limiter now consulted only on failed auth) + `handler_test.go`. Verified against the injected `users.VerifyCredentials` and the app's `/login` limiter usage. **What holds up (no finding):** - **Brute-force protection intact.** `loginLimiter.Allow` (`AllowN(now,1)`, burst 10, 1 token/6s) is consumed on *every* failed credential attempt (`handler.go:485`); once the per-IP bucket empties, further failed attempts get `401` + `ActionLoginRateLimited` audit. No path grants unlimited credential guesses — success is the only path that skips the limiter, and success requires valid creds. - **No enumeration/timing oracle.** `users.VerifyCredentials` still runs the dummy-hash bcrypt for unknown users (`service.go:234`); unknown-user and wrong-password are indistinguishable by time, and both the rate-limited and verify-failed branches return the identical `challengeBasicAuth` 401 — no status oracle. - **No auth bypass.** Invalid creds never return a `User`; valid creds return the authenticated user; the reorder cannot bypass either the credential check or the throttle. - **Drain bug fixed.** Valid authenticated asset traffic (~100 cover/thumbnail Basic-Auth hits per feed page) no longer consumes tokens — the original shared-bucket exhaustion that 401'd covers and throttled `/login` is gone. - **IP source + audit + PII.** `opdsClientIP` uses `RemoteAddr` only, never forwarded headers (`handler.go:565`). Both audit actions preserved; username is `SanitizeDescription`-wrapped; password never logged. --- [MAJOR] internal/opds/handler.go:482 — bcrypt now runs before the rate limiter (unauthenticated CPU-amplification DoS) The reorder moved `verifyCredentials` (bcrypt cost 12, ~hundreds of ms CPU/call, incl. the dummy-hash path for unknown users) to run BEFORE the limiter is consulted. The limiter is now reached only *after* the expensive hash, so an unauthenticated attacker sending unbounded `Authorization: Basic <garbage>` requests to any `/opds*` route forces a full bcrypt on every request with no per-IP ceiling — a small-request to large-server-cost amplification that a few concurrent connections can use to saturate CPU. This also diverges from the canonical `/login` handler (`internal/users/handler.go:136`), which checks `LoginAllow` FIRST, before `Login`/bcrypt, precisely to shield the hash. The token-drain fix itself is correct, but it removed the pre-bcrypt shield rather than making token consumption conditional. Fix: keep a cheap pre-bcrypt gate while consuming a token only on failure — two-phase: reject when the bucket is already empty *before* calling `verifyCredentials` (a non-consuming peek), run bcrypt only for non-throttled IPs, then consume a token on a failed credential check. That preserves both properties (valid traffic doesn't drain the bucket AND throttled IPs are rejected before the expensive hash). REVIEW VERDICT: 0 blocker, 1 major, 0 minor
fix(opds): two-phase rate limiter — peek pre-bcrypt, consume post-failure
All checks were successful
/ Test Race (pull_request) Successful in 4m2s
/ E2E API (pull_request) Successful in 3m16s
/ JS Unit Tests (pull_request) Successful in 1m20s
/ Coverage (pull_request) Successful in 4m11s
/ Lint (pull_request) Successful in 6m28s
/ Integration (pull_request) Successful in 6m19s
/ E2E Browser (pull_request) Successful in 6m2s
b4982d8151
Resolves security review MAJOR on PR #1195: the previous fix put bcrypt
(verifyCredentials) before the rate-limiter check, creating a CPU-amplification
DoS vector — an unauthenticated attacker could force a full bcrypt hash per
request with no per-IP ceiling.

Changes:
- Add LoginRateLimiter.Peek(ip) — non-consuming pre-bcrypt check using
  ReserveN+DelayFrom+CancelAt (clock-consistent with the injected fake clock)
- Refactor authenticate() to a two-phase design:
    Phase 1 (pre-bcrypt): if Peek denies, reject with 401+audit immediately,
    bcrypt is never called (verifyCredentials call-count == 0 when throttled)
    Phase 2 (post-failure): on bad creds, Consume one token + audit; on success,
    consume nothing (valid creds bypass the bucket entirely)
- Wire LoginPeek and LoginAllow as separate func args through all 4 OPDS handlers
  and appwire.Deps; app.go binds loginLimiter.Peek and loginLimiter.Allow
- Tests: throttled IP rejected without bcrypt called; valid creds consume 0 tokens;
  invalid creds consume a token; repeated invalid → throttled; Phase-2-only deny
  path (peek=allow, consume=deny) covered; Peek non-consuming property verified
Author
Owner

Two-Phase Rate-Limiter Review: PR #1195

Phase 1: Pre-Bcrypt Peek (DoS Shield) ✓

internal/opds/handler.go:562-567authenticate() calls loginPeek(ip) BEFORE verifyCredentials(). If peek returns false (bucket exhausted), request is rejected without running bcrypt.

internal/opds/handler_test.go:1784-1809 — Test "throttled IP (peek denies) is rejected before verifyCredentials is called" proves verifyCallCount=0 when peek denies. Bcrypt is shielded.

internal/users/ratelimiter.go:2381-2401 — Peek() implementation uses ReserveN(now,1) + DelayFrom(now) + CancelAt(now) to check without consuming. Clock-consistent for test compatibility.

internal/users/ratelimiter_test.go:2414-2459 — Peek() tests confirm: (a) returns true when bucket has tokens, (b) does not consume (Allow still succeeds after 10 Peeks), (c) returns false when bucket exhausted, (d) refills after time.

internal/opds/handler.go:563 — Nil-safe: if loginPeek != nil && !loginPeek(ip) handles disabled limiter.

Phase 2: Post-Failure Consume (Brute-Force Throttle) ✓

internal/opds/handler.go:569-576 — On verifyCredentials() failure, Phase 2 consumes via loginConsume(ip). Only reachable if Phase 1 peek passed.

internal/opds/handler_test.go:1811-1836 — Test "invalid credentials consume a token" proves failed attempt calls consume exactly once (consumeCallCount=1).

internal/opds/handler.go:572 — Nil-safe: if loginConsume != nil && !loginConsume(ip) handles disabled limiter.

Successful Auth (No Bucket Drain) ✓

internal/opds/handler.go:582 — Success path returns immediately without calling loginConsume. Prevents drain on normal polling.

internal/opds/handler_test.go:1754-1782 — Test "successful requests do not consume tokens (no bucket drain)" makes 5 successful requests and asserts consumeCallCount=0. Proven: zero token drain on success.

Wiring (No Nil-Func Trap) ✓

internal/app/app.go:34 — LoginPeek initialized: LoginPeek: loginLimiter.Peek.

internal/appwire/appwire.go:50 — LoginPeek declared in Deps.

internal/opds/wire.go:2303 — Wire reads both funcs and passes to all 4 handlers.

Module is by-value; LoginPeek field is set before usage. No nil-func trap.

No Regression ✓

internal/users/handler.go — No changes. Web /login path unchanged.

Ownership checks — OPDS routes are user-scoped. Fail-closed.

Test Hygiene ✓

  • package opds_test (black-box)
  • All It() blocks have exactly one Expect()
  • Curried-function stubs
  • Public interface only
  • Nil limiter tests

Metrics & Coverage ✓

  • No new .golangci.yml exclusions
  • scripts/check-coverage.sh: Added ./internal/opds/...
  • 40+ test specs

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

Phase 1 shields bcrypt from DoS. Phase 2 throttles brute-force. Successful requests never drain the bucket. No auth bypass. No regression. Review-ready.

# Two-Phase Rate-Limiter Review: PR #1195 ## Phase 1: Pre-Bcrypt Peek (DoS Shield) ✓ **internal/opds/handler.go:562-567** — `authenticate()` calls `loginPeek(ip)` BEFORE `verifyCredentials()`. If peek returns false (bucket exhausted), request is rejected without running bcrypt. **internal/opds/handler_test.go:1784-1809** — Test "throttled IP (peek denies) is rejected before verifyCredentials is called" proves `verifyCallCount=0` when peek denies. Bcrypt is shielded. **internal/users/ratelimiter.go:2381-2401** — Peek() implementation uses `ReserveN(now,1)` + `DelayFrom(now)` + `CancelAt(now)` to check without consuming. Clock-consistent for test compatibility. **internal/users/ratelimiter_test.go:2414-2459** — Peek() tests confirm: (a) returns true when bucket has tokens, (b) does not consume (Allow still succeeds after 10 Peeks), (c) returns false when bucket exhausted, (d) refills after time. **internal/opds/handler.go:563** — Nil-safe: `if loginPeek != nil && !loginPeek(ip)` handles disabled limiter. ## Phase 2: Post-Failure Consume (Brute-Force Throttle) ✓ **internal/opds/handler.go:569-576** — On `verifyCredentials()` failure, Phase 2 consumes via `loginConsume(ip)`. Only reachable if Phase 1 peek passed. **internal/opds/handler_test.go:1811-1836** — Test "invalid credentials consume a token" proves failed attempt calls consume exactly once (consumeCallCount=1). **internal/opds/handler.go:572** — Nil-safe: `if loginConsume != nil && !loginConsume(ip)` handles disabled limiter. ## Successful Auth (No Bucket Drain) ✓ **internal/opds/handler.go:582** — Success path returns immediately without calling loginConsume. Prevents drain on normal polling. **internal/opds/handler_test.go:1754-1782** — Test "successful requests do not consume tokens (no bucket drain)" makes 5 successful requests and asserts consumeCallCount=0. Proven: zero token drain on success. ## Wiring (No Nil-Func Trap) ✓ **internal/app/app.go:34** — LoginPeek initialized: `LoginPeek: loginLimiter.Peek`. **internal/appwire/appwire.go:50** — LoginPeek declared in Deps. **internal/opds/wire.go:2303** — Wire reads both funcs and passes to all 4 handlers. Module is by-value; LoginPeek field is set before usage. No nil-func trap. ## No Regression ✓ **internal/users/handler.go** — No changes. Web `/login` path unchanged. **Ownership checks** — OPDS routes are user-scoped. Fail-closed. ## Test Hygiene ✓ - `package opds_test` (black-box) - All It() blocks have exactly one Expect() - Curried-function stubs - Public interface only - Nil limiter tests ## Metrics & Coverage ✓ - No new `.golangci.yml` exclusions - scripts/check-coverage.sh: Added `./internal/opds/...` - 40+ test specs --- **REVIEW VERDICT: 0 blocker, 0 major, 0 minor** Phase 1 shields bcrypt from DoS. Phase 2 throttles brute-force. Successful requests never drain the bucket. No auth bypass. No regression. Review-ready.
Author
Owner

Security re-review — PR #1195 (two-phase OPDS rate limiter, 3rd iteration)

Scope: internal/opds/handler.go, internal/users/ratelimiter.go, internal/opds/wire.go, internal/appwire/appwire.go, internal/app/app.go. Adversarial verification that prior MAJOR #1 (shared-bucket drain by valid traffic) and MAJOR #2 (bcrypt-before-limiter DoS) are closed, and no third variant.

Confirmed CLOSED

  • MAJOR #1 (no drain) — CLOSED. authenticate runs loginPeek (non-consuming) pre-bcrypt and only calls loginConsume (Allow) on a failed credential check. A successful auth consumes zero tokens. Valid OPDS clients re-authenticating on every cover/thumbnail/file request no longer drain the shared loginLimiter bucket, so assets don't 401 and the shared web /login path isn't starved by legitimate OPDS traffic. LoginPeek/LoginAllow are the same loginLimiter instance (app.go:474-475), so peek and consume are coherent.
  • Peek non-consuming correctness (#4) — CORRECT. Peek uses ReserveN(now,1) + DelayFrom(now) + CancelAt(now). Verified against x/time@v0.15.0: CancelAt restores the reserved token (bounded by burst), for both the has-token case (timeToAct==now, not Before, restore 1) and the empty-bucket case (future reservation restored). No Reserve-without-Cancel leak; cannot be gamed to refill beyond the natural rate (restoration is bounded to this reservation's own token). The reserve/cancel race is over-throttle-only (safe direction). Clock-consistent for the injected fake clock.
  • Brute-force (sequential) preserved. Each failed attempt consumes a token; after 10 failures the bucket empties and Peek rejects further attempts pre-bcrypt. No unlimited-guess path; peek can't reset the bucket.
  • #5 hygiene — OK. IP from RemoteAddr only (opdsClientIP, no XFF trust). VerifyCredentials keeps the dummy-hash timing-equalization path (no enumeration oracle). Audit ActionLoginRateLimited/ActionLoginFailed recorded with SanitizeDescription(username); no password/secret logged. Path-traversal guard on download; parseID rejects non-positive. No nil-func wiring trap (both funcs set in app.New before opds.Wire consumes them).

Findings

[MAJOR] internal/opds/handler.go:authenticate (Phase-1 peek) + internal/users/ratelimiter.go:Peek — Non-consuming peek does not bound concurrent bcrypt; MAJOR #2 only partially closed.
Because Peek is non-consuming and the token-draining Allow runs only after verifyCredentials (bcrypt) completes, the entire bcrypt duration (~50-100ms) is a window in which any number of concurrent requests all observe the bucket as non-empty and all pass Phase 1 into bcrypt. Contrast the web /login handler (internal/users/handler.go:136 -> 143), which calls Allow (consuming) before bcrypt and therefore caps concurrent bcrypt at the burst (10). OPDS has no such cap: a single IP can drive as many concurrent bcrypt hashes as it has in-flight connections. Worse, the shield reopens on every refill — whenever a token replenishes (1 per 6s) the bucket is briefly >0 and a fresh concurrent wave again all peek-pass into bcrypt before the first failure consumes. Net effect: sustained CPU-amplification ~= (requests arriving within one bcrypt window) per refill, per IP — a real DoS on the exact axis this PR set out to fix. So I cannot fully confirm "bcrypt is genuinely not reached on a throttled IP" under concurrent load.
Fix (closes #1 and #2 together): move the consume before bcrypt and refund on success — reserve/consume a token pre-bcrypt (reject if none), then Cancel/restore it iff verifyCredentials succeeds. That caps concurrent bcrypt at the burst while still consuming nothing for valid clients. (A per-IP in-flight semaphore around verifyCredentials is an alternative, but the reserve-then-refund pattern is cleaner and reuses the existing limiter.)

Verdict

REVIEW VERDICT: 0 blocker, 1 major, 0 minor

## Security re-review — PR #1195 (two-phase OPDS rate limiter, 3rd iteration) Scope: `internal/opds/handler.go`, `internal/users/ratelimiter.go`, `internal/opds/wire.go`, `internal/appwire/appwire.go`, `internal/app/app.go`. Adversarial verification that prior MAJOR #1 (shared-bucket drain by valid traffic) and MAJOR #2 (bcrypt-before-limiter DoS) are closed, and no third variant. ### Confirmed CLOSED - **MAJOR #1 (no drain) — CLOSED.** `authenticate` runs `loginPeek` (non-consuming) pre-bcrypt and only calls `loginConsume` (`Allow`) on a *failed* credential check. A successful auth consumes **zero** tokens. Valid OPDS clients re-authenticating on every cover/thumbnail/file request no longer drain the shared `loginLimiter` bucket, so assets don't 401 and the shared web `/login` path isn't starved by legitimate OPDS traffic. `LoginPeek`/`LoginAllow` are the *same* `loginLimiter` instance (`app.go:474-475`), so peek and consume are coherent. - **Peek non-consuming correctness (#4) — CORRECT.** `Peek` uses `ReserveN(now,1)` + `DelayFrom(now)` + `CancelAt(now)`. Verified against `x/time@v0.15.0`: `CancelAt` restores the reserved token (bounded by burst), for both the has-token case (`timeToAct==now`, not `Before`, restore 1) and the empty-bucket case (future reservation restored). No `Reserve`-without-`Cancel` leak; cannot be gamed to *refill* beyond the natural rate (restoration is bounded to this reservation's own token). The reserve/cancel race is over-throttle-only (safe direction). Clock-consistent for the injected fake clock. - **Brute-force (sequential) preserved.** Each failed attempt consumes a token; after 10 failures the bucket empties and `Peek` rejects further attempts pre-bcrypt. No unlimited-guess path; peek can't reset the bucket. - **#5 hygiene — OK.** IP from `RemoteAddr` only (`opdsClientIP`, no XFF trust). `VerifyCredentials` keeps the dummy-hash timing-equalization path (no enumeration oracle). Audit `ActionLoginRateLimited`/`ActionLoginFailed` recorded with `SanitizeDescription(username)`; no password/secret logged. Path-traversal guard on download; `parseID` rejects non-positive. No nil-func wiring trap (both funcs set in `app.New` before `opds.Wire` consumes them). ### Findings [MAJOR] internal/opds/handler.go:authenticate (Phase-1 peek) + internal/users/ratelimiter.go:Peek — Non-consuming peek does not bound *concurrent* bcrypt; MAJOR #2 only partially closed. Because `Peek` is non-consuming and the token-draining `Allow` runs only **after** `verifyCredentials` (bcrypt) completes, the entire bcrypt duration (~50-100ms) is a window in which *any* number of concurrent requests all observe the bucket as non-empty and all pass Phase 1 into bcrypt. Contrast the web `/login` handler (`internal/users/handler.go:136` -> `143`), which calls `Allow` (consuming) **before** bcrypt and therefore caps concurrent bcrypt at the burst (10). OPDS has no such cap: a single IP can drive as many concurrent bcrypt hashes as it has in-flight connections. Worse, the shield reopens on every refill — whenever a token replenishes (1 per 6s) the bucket is briefly `>0` and a fresh concurrent wave again all peek-pass into bcrypt before the first failure consumes. Net effect: sustained CPU-amplification ~= (requests arriving within one bcrypt window) per refill, per IP — a real DoS on the exact axis this PR set out to fix. So I cannot fully confirm "bcrypt is genuinely not reached on a throttled IP" under concurrent load. Fix (closes #1 and #2 together): move the consume *before* bcrypt and **refund on success** — reserve/consume a token pre-bcrypt (reject if none), then `Cancel`/restore it iff `verifyCredentials` succeeds. That caps concurrent bcrypt at the burst while still consuming nothing for valid clients. (A per-IP in-flight semaphore around `verifyCredentials` is an alternative, but the reserve-then-refund pattern is cleaner and reuses the existing limiter.) ### Verdict REVIEW VERDICT: 0 blocker, 1 major, 0 minor
fix(opds): reserve-then-refund rate-limiter closes concurrent-bcrypt DoS + drain (bookshelf-6tq2k.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m32s
/ Test Race (pull_request) Successful in 2m56s
/ Coverage (pull_request) Successful in 2m59s
/ E2E API (pull_request) Successful in 2m43s
/ Lint (pull_request) Successful in 5m6s
/ E2E Browser (pull_request) Successful in 5m3s
/ Integration (pull_request) Successful in 5m35s
dcdd9d72c8
Replace the two-phase peek/consume design with the canonical reserve-then-refund
pattern:
- Before bcrypt: ReserveToken consumes one token atomically. If the bucket is
  empty the request is rejected with 401 + ActionLoginRateLimited without ever
  calling verifyCredentials, capping concurrent bcrypt at ~burst per IP.
- On success: refund() cancels the reservation so poll-heavy OPDS clients that
  re-authenticate on every asset request net zero token consumption (no drain).
- On failure: token stays consumed + ActionLoginFailed fires. Brute-force
  throttling is preserved.

Closes the three concurrent-DoS/drain/sequential-DoS variants flagged in the
security re-review. Removes the now-unused Peek method and LoginPeek wiring.

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

Security re-review (round 3) — M4B/CBZ binary rewriters

Scope: fix commits on bd-bookshelf-r24bc.3 @ ce6ef75einternal/files/m4b_metadata_write.go, internal/files/cbz_metadata_write.go, internal/app/build_extended_deps.go. Focus: residual data-loss MAJORs + any remaining silent-truncation / uncapped-read in the rewriters.

Residual MAJORs from round 2 — both CONFIRMED CLOSED

  • M4B 64-bit largesize branch (m4b_metadata_write.go:126-152): now returns ErrInvalidM4B and aborts on size64 < 16 (below its own 16-byte header), on size64 > remaining, and on a truncated 16-byte header. No truncated mdat can be written over the original. CLOSED.
  • CBZ pre-read size cap (cbz_metadata_write.go:89-99): statFile runs and rejects size > maxFileBytes with ErrInvalidCBZ before the whole-file readFile. Correctly wired in build_extended_deps.go:2793-2811 (os.Stat shim + DefaultMaxCBZFileBytes passed). CLOSED.

Adversarial pass on the binary parsing — the audio-drop class is closed

  • m4bParseTopLevel now errors (aborts the whole write) on every malformed top-level atom, so mdat can never be silently skipped. In rewriteM4BTags every top-level atom is re-emitted verbatim except moov (rebuilt); mdat is copied byte-for-byte, so the irreplaceable audio is provably preserved on any file that reaches the write. Offset/length arithmetic is 64-bit-safe (no int overflow on the size sums; every m4bReadUintNBE call is guarded by an off+N <= len bound so no slice panic). stco/co64 delta loops are bounded by bodyEnd regardless of the declared count so no OOM. chunk-offset fixup is correctly scoped to the rebuilt moov body. No BLOCKER/MAJOR here.
  • CBZ OOM is genuinely bounded — not just by the declared-size guard. cbzRebuildZIP rejects entries whose UncompressedSize64 > maxEPUBEntryBytes (50MB) up front (catches lying-large headers), and Go's archive/zip checksumReader.Read returns ErrFormat the instant nread > UncompressedSize64 (reader.go:302-303), so a lying-small header cannot make io.ReadAll(rc) over-read either. Per-entry read is capped; total is bounded by the 2GB file cap. No unbounded read/alloc in the rebuild. No finding.
  • Atomic temp+rename intact (write.go): rewrite errors return before atomicWrite is called, so a rejected file never partially overwrites the source; the temp file is cleaned up on any write/close/rename failure. Path comes from the DB (GetPrimaryBookFilePath), not the request. internal/files imports no go-workflows (only doc-comment references). No secrets/PII logged (these files log nothing). No regressions.

Findings

[MINOR] internal/files/m4b_metadata_write.go:349,375 — inner moov/udta walkers break (silent truncate) on a malformed child box
m4bRebuildMoovBody / m4bRebuildUdtaBody break on size32 < 8 || off+size32 > len(body), silently dropping the remaining moov children and writing a truncated moov over the source. Impact is bounded (mdat/audio is a top-level atom copied verbatim, so audio is never lost) and only reachable on an already-internally-corrupt moov, so this is not the catastrophic class — but it is inconsistent with the m4bParseTopLevel hardening (error-not-break). These walkers also assume 8-byte headers throughout (no largesize handling). Fix: have the inner walkers return ErrInvalidM4B on a malformed child instead of break, matching the top-level parser, so a corrupt moov aborts the write rather than overwriting the original with a re-truncated file.

[MINOR] internal/files/cbz_metadata_write.go:207,219 — rebuilt-ZIP zw.Close() / ew.Write() errors are swallowed
_ = zw.Close() and _, _ = ew.Write(entryData) ignore errors. In practice a bytes.Buffer-backed zip.Writer never errors, so this is theoretical — but Close() is where the central directory is flushed, and given the output is atomically written over an irreplaceable source, a genuine error there would emit a corrupt archive over the original. Fix: check zw.Close() (and ideally the entry Write) and return ErrInvalidCBZ on failure, so a malformed rebuild aborts before the rename.

REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## Security re-review (round 3) — M4B/CBZ binary rewriters Scope: fix commits on `bd-bookshelf-r24bc.3` @ ce6ef75e — `internal/files/m4b_metadata_write.go`, `internal/files/cbz_metadata_write.go`, `internal/app/build_extended_deps.go`. Focus: residual data-loss MAJORs + any remaining silent-truncation / uncapped-read in the rewriters. ### Residual MAJORs from round 2 — both CONFIRMED CLOSED - **M4B 64-bit largesize branch** (`m4b_metadata_write.go:126-152`): now returns `ErrInvalidM4B` and aborts on `size64 < 16` (below its own 16-byte header), on `size64 > remaining`, and on a truncated 16-byte header. No truncated `mdat` can be written over the original. CLOSED. - **CBZ pre-read size cap** (`cbz_metadata_write.go:89-99`): `statFile` runs and rejects `size > maxFileBytes` with `ErrInvalidCBZ` **before** the whole-file `readFile`. Correctly wired in `build_extended_deps.go:2793-2811` (`os.Stat` shim + `DefaultMaxCBZFileBytes` passed). CLOSED. ### Adversarial pass on the binary parsing — the audio-drop class is closed - `m4bParseTopLevel` now **errors** (aborts the whole write) on every malformed top-level atom, so `mdat` can never be silently skipped. In `rewriteM4BTags` every top-level atom is re-emitted verbatim except `moov` (rebuilt); `mdat` is copied byte-for-byte, so the irreplaceable audio is provably preserved on any file that reaches the write. Offset/length arithmetic is 64-bit-safe (no int overflow on the size sums; every `m4bReadUintNBE` call is guarded by an `off+N <= len` bound so no slice panic). `stco`/`co64` delta loops are bounded by `bodyEnd` regardless of the declared count so no OOM. chunk-offset fixup is correctly scoped to the rebuilt `moov` body. No BLOCKER/MAJOR here. - **CBZ OOM is genuinely bounded** — not just by the declared-size guard. `cbzRebuildZIP` rejects entries whose `UncompressedSize64 > maxEPUBEntryBytes` (50MB) up front (catches lying-large headers), and Go's `archive/zip` `checksumReader.Read` returns `ErrFormat` the instant `nread > UncompressedSize64` (reader.go:302-303), so a lying-*small* header cannot make `io.ReadAll(rc)` over-read either. Per-entry read is capped; total is bounded by the 2GB file cap. No unbounded read/alloc in the rebuild. No finding. - Atomic temp+rename intact (`write.go`): rewrite errors return **before** `atomicWrite` is called, so a rejected file never partially overwrites the source; the temp file is cleaned up on any write/close/rename failure. Path comes from the DB (`GetPrimaryBookFilePath`), not the request. `internal/files` imports no go-workflows (only doc-comment references). No secrets/PII logged (these files log nothing). No regressions. ### Findings [MINOR] internal/files/m4b_metadata_write.go:349,375 — inner moov/udta walkers `break` (silent truncate) on a malformed child box `m4bRebuildMoovBody` / `m4bRebuildUdtaBody` `break` on `size32 < 8 || off+size32 > len(body)`, silently dropping the remaining `moov` children and writing a truncated `moov` over the source. Impact is bounded (mdat/audio is a top-level atom copied verbatim, so audio is never lost) and only reachable on an already-internally-corrupt `moov`, so this is not the catastrophic class — but it is inconsistent with the `m4bParseTopLevel` hardening (error-not-break). These walkers also assume 8-byte headers throughout (no largesize handling). Fix: have the inner walkers return `ErrInvalidM4B` on a malformed child instead of `break`, matching the top-level parser, so a corrupt `moov` aborts the write rather than overwriting the original with a re-truncated file. [MINOR] internal/files/cbz_metadata_write.go:207,219 — rebuilt-ZIP `zw.Close()` / `ew.Write()` errors are swallowed `_ = zw.Close()` and `_, _ = ew.Write(entryData)` ignore errors. In practice a `bytes.Buffer`-backed `zip.Writer` never errors, so this is theoretical — but `Close()` is where the central directory is flushed, and given the output is atomically written over an irreplaceable source, a genuine error there would emit a corrupt archive over the original. Fix: check `zw.Close()` (and ideally the entry `Write`) and return `ErrInvalidCBZ` on failure, so a malformed rebuild aborts before the rename. REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

Security re-review (FINAL, adversarial) — reserve-then-refund OPDS rate limiter

Verdict up front: the reserve-then-refund design is sound, but this implementation of the refund is broken in production and reintroduces the original drain (MAJOR #1). The bug is masked by the frozen-clock unit tests, which is why it survived to iteration 4.

[BLOCKER] internal/users/ratelimiter.go:127 — refund is a no-op under the real (production) clock; drain (MAJOR #1) is NOT fixed
  ReserveToken returns the success refund as `func() { r.CancelAt(l.now()) }`, evaluating the
  clock at REFUND time. golang.org/x/time/rate's CancelAt(t) short-circuits when
  `r.timeToAct.Before(t)` (rate.go). For an *immediate* reservation timeToAct == the reserve
  instant, so once any real time has elapsed — and bcrypt in verifyCredentials guarantees
  ~50-100ms — the refund-time `l.now()` is strictly after timeToAct, the guard trips, and
  CancelAt restores NOTHING. In production (now=time.Now) every successful auth therefore
  consumes a token and never gives it back: the bucket drains 10->9->...->0, then legitimate
  requests are rejected. A poll-heavy OPDS client (re-auth on every cover/thumbnail/file
  request; a single 50-book feed page emits >10 asset fetches) is throttled within seconds —
  exactly the drain this iteration was built to eliminate.

  Empirically verified against x/time v0.15.0: real-clock refund drains to REJECTED after 10;
  frozen-clock refund (what the unit tests inject) stays pinned at 10.000 — so the tests pass
  while production is broken. Note the internal inconsistency that proves the bug: the REJECT
  path (ratelimiter.go:123) calls `r.CancelAt(now)` with the RESERVE-time `now` and correctly
  restores; only the SUCCESS refund uses the later `l.now()`.

  Fix: capture the reserve-time value and cancel with it —
      return true, func() { r.CancelAt(now) }
  (`now` is already in scope from `now := l.now()`). Verified: under a real clock this keeps the
  bucket full on success while failures — which never call refund — still drain and throttle
  after burst. Works identically under the frozen test clock. Also add a real-clock (non-frozen
  or advanced-clock) test asserting N>burst *successful* auths from one IP are NOT throttled, so
  this regression cannot hide behind a frozen clock again.

[MINOR] internal/opds/handler.go authenticate (post-fix consideration) — valid-cred concurrent-bcrypt amplification
  Once the refund is fixed, a single VALID credential lets one IP sustain up to `burst` (10)
  concurrent bcrypt hashes continuously (each success refunds, so the cap only bites between
  completion and the next reserve), whereas the web /login path (consuming Allow, no refund)
  throttles the same attacker to ~10/min. This is the deliberate, bounded trade-off of
  refund-on-success and requires authentication, so it is acceptable — but keep `burst`
  single-digit/configurable and be aware OPDS gives an authenticated attacker more sustained
  hash throughput than /login. Not blocking.

[MINOR] internal/opds/handler.go opdsClientIP — duplicates users.clientIP verbatim
  opdsClientIP is byte-identical to internal/users clientIP (RemoteAddr-only, no XFF trust —
  which is correct). Consider exporting/reusing the one helper to avoid drift. No security impact.

Everything else verified clean:

  • Concurrent bcrypt bound (was MAJOR #3): holds. The reserve is a real consume held across bcrypt and only refunded AFTER it completes, so at most burst reservations (hence <=burst concurrent hashes) per IP exist at any instant; the burst+1th finds an empty bucket and is rejected pre-hash. Holds under both the broken and the fixed refund.
  • Brute-force (original): preserved. Failed attempts never call refund -> bucket empties after burst failures -> further attempts rejected pre-bcrypt. Empirically confirmed.
  • No over-refund / cross-bucket refund (4b): CancelAt caps restored tokens at burst; the refund closure is bound to the same reservation r/e.limiter (same IP), so it cannot exceed capacity or credit another IP.
  • Token-leak (4a): the only code between reserve and refund is verifyCredentials; its error path is the intended no-refund (throttle) branch. A panic would leak one token, but it self-heals via refill and is not a normal path.
  • Reject path / clock race (4d): ReserveN+CancelAt run under rate.Limiter's own mutex; the reject branch cancels with reserve-time now and does not consume; no refill-faster-than-rate race.
  • IP source (5): opdsClientIP uses RemoteAddr only, never X-Forwarded-For — no per-IP bypass. /login unchanged (still loginLimiter.Allow, consuming). Audits preserved (ActionLoginRateLimited / ActionLoginFailed). No password/secret logged; username sanitized. LoginReserve wired as a non-nil method value in app.New; authenticate nil-guards it.

REVIEW VERDICT: 1 blocker, 0 major, 2 minor

## Security re-review (FINAL, adversarial) — reserve-then-refund OPDS rate limiter Verdict up front: the reserve-then-refund **design** is sound, but this **implementation of the refund is broken in production** and reintroduces the original drain (MAJOR #1). The bug is masked by the frozen-clock unit tests, which is why it survived to iteration 4. ``` [BLOCKER] internal/users/ratelimiter.go:127 — refund is a no-op under the real (production) clock; drain (MAJOR #1) is NOT fixed ReserveToken returns the success refund as `func() { r.CancelAt(l.now()) }`, evaluating the clock at REFUND time. golang.org/x/time/rate's CancelAt(t) short-circuits when `r.timeToAct.Before(t)` (rate.go). For an *immediate* reservation timeToAct == the reserve instant, so once any real time has elapsed — and bcrypt in verifyCredentials guarantees ~50-100ms — the refund-time `l.now()` is strictly after timeToAct, the guard trips, and CancelAt restores NOTHING. In production (now=time.Now) every successful auth therefore consumes a token and never gives it back: the bucket drains 10->9->...->0, then legitimate requests are rejected. A poll-heavy OPDS client (re-auth on every cover/thumbnail/file request; a single 50-book feed page emits >10 asset fetches) is throttled within seconds — exactly the drain this iteration was built to eliminate. Empirically verified against x/time v0.15.0: real-clock refund drains to REJECTED after 10; frozen-clock refund (what the unit tests inject) stays pinned at 10.000 — so the tests pass while production is broken. Note the internal inconsistency that proves the bug: the REJECT path (ratelimiter.go:123) calls `r.CancelAt(now)` with the RESERVE-time `now` and correctly restores; only the SUCCESS refund uses the later `l.now()`. Fix: capture the reserve-time value and cancel with it — return true, func() { r.CancelAt(now) } (`now` is already in scope from `now := l.now()`). Verified: under a real clock this keeps the bucket full on success while failures — which never call refund — still drain and throttle after burst. Works identically under the frozen test clock. Also add a real-clock (non-frozen or advanced-clock) test asserting N>burst *successful* auths from one IP are NOT throttled, so this regression cannot hide behind a frozen clock again. [MINOR] internal/opds/handler.go authenticate (post-fix consideration) — valid-cred concurrent-bcrypt amplification Once the refund is fixed, a single VALID credential lets one IP sustain up to `burst` (10) concurrent bcrypt hashes continuously (each success refunds, so the cap only bites between completion and the next reserve), whereas the web /login path (consuming Allow, no refund) throttles the same attacker to ~10/min. This is the deliberate, bounded trade-off of refund-on-success and requires authentication, so it is acceptable — but keep `burst` single-digit/configurable and be aware OPDS gives an authenticated attacker more sustained hash throughput than /login. Not blocking. [MINOR] internal/opds/handler.go opdsClientIP — duplicates users.clientIP verbatim opdsClientIP is byte-identical to internal/users clientIP (RemoteAddr-only, no XFF trust — which is correct). Consider exporting/reusing the one helper to avoid drift. No security impact. ``` Everything else verified clean: - **Concurrent bcrypt bound (was MAJOR #3):** holds. The reserve is a real consume held across bcrypt and only refunded AFTER it completes, so at most `burst` reservations (hence <=burst concurrent hashes) per IP exist at any instant; the burst+1th finds an empty bucket and is rejected pre-hash. Holds under both the broken and the fixed refund. - **Brute-force (original):** preserved. Failed attempts never call refund -> bucket empties after `burst` failures -> further attempts rejected pre-bcrypt. Empirically confirmed. - **No over-refund / cross-bucket refund (4b):** CancelAt caps restored tokens at `burst`; the refund closure is bound to the same reservation `r`/`e.limiter` (same IP), so it cannot exceed capacity or credit another IP. - **Token-leak (4a):** the only code between reserve and refund is verifyCredentials; its error path is the intended no-refund (throttle) branch. A panic would leak one token, but it self-heals via refill and is not a normal path. - **Reject path / clock race (4d):** ReserveN+CancelAt run under rate.Limiter's own mutex; the reject branch cancels with reserve-time `now` and does not consume; no refill-faster-than-rate race. - **IP source (5):** opdsClientIP uses RemoteAddr only, never X-Forwarded-For — no per-IP bypass. `/login` unchanged (still loginLimiter.Allow, consuming). Audits preserved (ActionLoginRateLimited / ActionLoginFailed). No password/secret logged; username sanitized. LoginReserve wired as a non-nil method value in app.New; authenticate nil-guards it. REVIEW VERDICT: 1 blocker, 0 major, 2 minor
fix(users): capture reserve-time now in ReserveToken refund closure
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m35s
/ E2E API (pull_request) Successful in 2m25s
/ Test Race (pull_request) Successful in 3m52s
/ Coverage (pull_request) Successful in 4m7s
/ Lint (pull_request) Successful in 4m12s
/ E2E Browser (pull_request) Successful in 4m33s
/ Integration (pull_request) Successful in 5m35s
cbbe2bff8a
The refund closure in ReserveToken was calling r.CancelAt(l.now()) at
refund time, which is after the clock has advanced (by ~50-100ms while
bcrypt runs). rate.Reservation.CancelAt restores nothing when
timeToAct < t, so every successful auth permanently consumed a token —
the bucket drained 10→0 within seconds for poll-heavy OPDS clients.

Fix: capture the reserve-time `now` variable in the closure (already
in scope, already used correctly on the reject path) so CancelAt fires
at the original reserve instant and the token is genuinely restored.

Add a moving-clock regression test (20 cycles with 100ms clock advance
between reserve and refund): the test goes RED on the buggy code (bucket
drains after burst=10 iterations) and GREEN on the fix.

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

Security re-review — BLOCKER fix verification (refund-clock token-drain)

Verdict: the BLOCKER is genuinely closed. Reviewing internal/users/ratelimiter.go + internal/users/ratelimiter_test.go on bd-bookshelf-6tq2k.1 (fix commit cbbe2bff).

1. Fix correctness — CONFIRMED.
ReserveToken captures now := l.now() once, passes it to ReserveN(now, 1), and both exit paths use that same reserve-time value:

  • reject path: r.CancelAt(now) (ratelimiter.go:125)
  • refund closure: func() { r.CancelAt(now) } (ratelimiter.go:128)

rate.Reservation.CancelAt(t) restores nothing when r.timeToAct.Before(t). For an immediately-available reservation timeToAct == now (reserve instant). Old code passed refund-time l.now() (advanced ~50-100ms by bcrypt) → timeToAct.Before(refundTime) true → no restore → drain. Fix passes reserve-time nownow.Before(now) false → token genuinely restored under a real, moving clock. Closed.

2. Regression test is GENUINE — CONFIRMED.
The moving-clock Context (ratelimiter_test.go:195) is NOT frozen: it advances step += 100ms between reserve and refund each cycle (simulating bcrypt latency). It runs cycles = 20 > burst = 10 and asserts HaveEach(BeTrue()) plus finalOK == true. Trace against the OLD CancelAt(l.now()) code: each cycle nets ~1 token lost (refund is a no-op), so around cycle ~10 the bucket empties, DelayFrom(now) > 0ReserveToken returns false → HaveEach(BeTrue()) fails and finalOK false → RED. Against the fix every cycle restores → GREEN. The test pins the exact regression.

3. No new issue — CONFIRMED.

  • No over-refund: CancelAt restores at most the one reserved token and the limiter caps at burst=10; the trace stays at ~10, never above.
  • Reject path unchanged (r.CancelAt(now) was already reserve-time) — throttle intact.
  • Failure-consume path unchanged: internal/opds/handler.go:511-517 returns on verify failure WITHOUT calling refund, so wrong-password attempts keep the token consumed and exhaust the bucket pre-bcrypt (brute-force + concurrent-bcrypt DoS bound preserved). Success path (handler.go:519-522) calls refund() exactly once.

REVIEW VERDICT: 0 blocker, 0 major, 0 minor

## Security re-review — BLOCKER fix verification (refund-clock token-drain) **Verdict: the BLOCKER is genuinely closed.** Reviewing `internal/users/ratelimiter.go` + `internal/users/ratelimiter_test.go` on `bd-bookshelf-6tq2k.1` (fix commit `cbbe2bff`). **1. Fix correctness — CONFIRMED.** `ReserveToken` captures `now := l.now()` once, passes it to `ReserveN(now, 1)`, and both exit paths use that same reserve-time value: - reject path: `r.CancelAt(now)` (ratelimiter.go:125) - refund closure: `func() { r.CancelAt(now) }` (ratelimiter.go:128) `rate.Reservation.CancelAt(t)` restores nothing when `r.timeToAct.Before(t)`. For an immediately-available reservation `timeToAct == now` (reserve instant). Old code passed refund-time `l.now()` (advanced ~50-100ms by bcrypt) → `timeToAct.Before(refundTime)` true → no restore → drain. Fix passes reserve-time `now` → `now.Before(now)` false → token genuinely restored under a real, moving clock. Closed. **2. Regression test is GENUINE — CONFIRMED.** The moving-clock `Context` (ratelimiter_test.go:195) is NOT frozen: it advances `step += 100ms` between reserve and refund each cycle (simulating bcrypt latency). It runs `cycles = 20 > burst = 10` and asserts `HaveEach(BeTrue())` plus `finalOK == true`. Trace against the OLD `CancelAt(l.now())` code: each cycle nets ~1 token lost (refund is a no-op), so around cycle ~10 the bucket empties, `DelayFrom(now) > 0` → `ReserveToken` returns false → `HaveEach(BeTrue())` fails and `finalOK` false → RED. Against the fix every cycle restores → GREEN. The test pins the exact regression. **3. No new issue — CONFIRMED.** - No over-refund: `CancelAt` restores at most the one reserved token and the limiter caps at `burst=10`; the trace stays at ~10, never above. - Reject path unchanged (`r.CancelAt(now)` was already reserve-time) — throttle intact. - Failure-consume path unchanged: `internal/opds/handler.go:511-517` returns on verify failure WITHOUT calling `refund`, so wrong-password attempts keep the token consumed and exhaust the bucket pre-bcrypt (brute-force + concurrent-bcrypt DoS bound preserved). Success path (handler.go:519-522) calls `refund()` exactly once. REVIEW VERDICT: 0 blocker, 0 major, 0 minor
zombor force-pushed bd-bookshelf-6tq2k.1 from cbbe2bff8a
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m35s
/ E2E API (pull_request) Successful in 2m25s
/ Test Race (pull_request) Successful in 3m52s
/ Coverage (pull_request) Successful in 4m7s
/ Lint (pull_request) Successful in 4m12s
/ E2E Browser (pull_request) Successful in 4m33s
/ Integration (pull_request) Successful in 5m35s
to 28f9edcf15
All checks were successful
/ JS Unit Tests (pull_request) Successful in 3m31s
/ E2E API (pull_request) Successful in 5m37s
/ Test Race (pull_request) Successful in 6m48s
/ Coverage (pull_request) Successful in 7m11s
/ E2E Browser (pull_request) Successful in 7m29s
/ Integration (pull_request) Successful in 7m44s
/ Lint (pull_request) Successful in 7m47s
2026-07-22 16:48:41 +00:00
Compare
zombor merged commit a75b1dac85 into main 2026-07-22 16:57:08 +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!1195
No description provided.