fix(books): NULL-aware cursor pagination for nullable sort columns (bookshelf-t582g.14) #1252
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t582g.14"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
(col IS NULL) ASCISNULL trick toORDER BYso NULLs sort last in both ASC and DESC, forming a stable total order. Adds aNULL cursorpredicate(col IS NULL AND id dir ?)— the missing predicate caused an infinite-scroll loop when paging into the NULL tail.buildMultiSortOrderBy): prepends(col IS NULL) ASCfor each nullable sort term; fixesbuildMultiCursorPredicateso that for non-NULL cursor values, NULLs are included in the "after" set in both ASC and DESC (they now sort last in both directions).""not nil, so theelse if p.CursorID != 0branch for series was unreachable; deleted.(col, book_id)indexes cannot eliminate filesort for the ISNULL-prefixed ORDER BY; a future follow-up covering functional index per nullable column is noted in a comment nearnullableCol.Test plan
internal/books/nullable_cursor_test.gocovering:(col IS NULL) ASCfor every nullable column in both single-field and multi-field pathspublished_dateASC + NULL cursor branch (was previously uncovered)DescribeTablepagination simulation for multiple nullable column + direction combinationsstore_test.goassertions that checked the oldORDER BY col dirformat now check for the new format with ISNULL trickCloses bead bookshelf-t582g.14 on merge.
Security Review — PR #1252 (NULL-aware cursor pagination)
Scope: SQL injection surface of the new nullable-column ORDER BY + cursor predicates in
internal/books/store.go; multi-user library scoping / content-restriction integrity; crafted-cursor bypass / unbounded-scan risk.Findings
No blocker, major, or minor security findings.
Verified — SQL injection (identifiers from allowlist, values parameterized):
sortKeyColumn/nullableColmaps keyed by theSortKeytype (store.go:1996,:2016);seriesSortExpr/titleSortExprare package constants. No user string is ever concatenated into a column position.t.Order/p.SortOrdercompared against the literal set{asc,desc}— never interpolated raw.parseSort/parseMultiSort,handler.go:610/:642) rejects any?sort=/?order=value not in the closedvalidSortKeysmap or{asc,desc}withErrValidation(400). The store never sees an unlisted key.cv,CursorID) is bound with?; the new NULL-tail branches(bm.col IS NULL AND bm.book_id op ?)and the(col IS NULL OR col op ?)change inbuildMultiCursorPredicateadd only parameterized placeholders.CursorSortValis type-asserted (.(time.Time)/.(int64)/…) withErrValidationon mismatch.Verified — multi-user scoping / content restrictions preserved (AND, not replace):
strings.Join(wheres, "\n AND ")in bothbuildListBooksFilteredQuery(store.go:1915) andbuildMultiFieldListQuery(:1543). The cursor predicate is appended as one more slice element and thus ANDs with every scoping clause.deleted(sharedWheresfrombuildBookFilterPredicates), the per-userLEFT JOIN user_book_progress … AND ubp.user_id = ?,MagicWhere, andContentRestrictionPredicatesare all appended towheresbefore the sort/cursor switch and are untouched by this diff."(" + join(branches) + ")"), so its internal ORs cannot disjoin with scoping clauses.Verified — no crafted-cursor bypass / unbounded scan:
CursorID/CursorSortValonly shifts the keyset window; it is ANDed under all scoping predicates, so it cannot reach cross-user or restricted rows.LIMIT ?is unconditionally appended on every path (store.go:1918,:1546), so no cursor value produces an unbounded result set.buildListBooksByIDsQuerypath and all scoping predicates are unmodified by this diff (all 6 hunks are confined to sort/cursor ORDER BY logic).REVIEW VERDICT: 0 blocker, 0 major, 0 minor
Security Review — PR #1240 (ComicInfo.xml → exact-by-ID provider fetch)
Adversarial focus: ComicInfo.xml is attacker-controlled (inside an untrusted .cbz/.cbr).
SSRF (untrusted
<Web>/<Notes>steering outbound host) — CLEAN. The rawURL is never used as a request target.
extractProviderIDFromComicInfo(
internal/bookdrop/comicinfo_provider_id.go) only regex-captures a numeric issueID (
4000-(\d+),metron\.cloud/issue/(\d+),\[ComicVine:4000-(\d+)\]). Bothproviders build the request from the FIXED provider base URL:
fmt.Sprintf("%s/issue/4000-%d/?%s", baseURL, issueID, ...)(internal/metadata/comicvine/search.go:778),baseURL=defaultBaseURL/test server.fmt.Sprintf("%s/issue/%d/", d.baseURL, issueID)(internal/metadata/metron/provider.go:425).The persisted
WebLinkalso passes throughurlutil.SafeURL(internal/comic/persist.go:163).Injection / request-splitting — CLEAN. The ID is parsed with
strconv.ParseInt(match[1], 10, 64)and rejected unless> 0; it is emitted as%d. No non-numeric content can reach the path/query. Provider tag is a fixedliteral (
"comicvine"/"metron"), never concatenated into a URL.XXE / XML bomb — CLEAN. Parsing uses Go
encoding/xml(decodeComicInfoReader,internal/comic/comicinfo.go:258) with noCharsetReaderand no customEntitymap — external/DTD entities are not resolved by default. Decompressed size is
double-bounded: the zip/rar header uncompressed-size guard rejects
> maxComicInfoBytes(1 MB) before open, and anio.LimitReader(rc, cap)caps thedecode. Archive read is
io.LimitReader(f, 512MB). Zip entry is read, notextracted to disk (no zip-slip).
API keys — CLEAN. New log lines emit only
issue_id/volume_id/err(
internal/metadata/comicvine/provider.go); noapi_keyin the new code.Permanent vs transient — CORRECT. A malformed/missing/unsupported archive →
readComicArchiveerror/ErrNoComicInfois swallowed and falls back to fuzzysearch (
fetchMergedCandidateForProposal). A provider 404 for anattacker-forged ID maps to
metadata.ErrNoMatch(fall through to otherproviders), not a hard error. Exact-by-ID fetch still runs under the existing
rate limiter and only for already-enabled/active providers.
[MINOR] internal/bookdrop/import_metadata_service.go:15 — import out of goimports order
internal/comicis placed afterinternal/db/sqlc(comic < db/sqlc). Ifgolangci-lint/gci is not flagging it, harmless, but reorder for tidiness. No
security impact.
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
658e494b1cb6c551bfba