fix(bookdrop): prevent source data-loss on staging failure + dest-collision overwrite (bookshelf-t582g.7 + t582g.8) #1251
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-t582g.7"
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
stageFileusedos.Renameto move the source into the staging temp on same-device. Any subsequent failure (TX error, finalize error) cleaned up the temp file — destroying the user's only copy while the proposal stayedPENDING_REVIEW. Fix: remove the rename fast-path entirely; always copy+fsync so the source is always intact for retry.AcceptProposalcomputeddestPath = filepath.Join(destDir, safeFileName)with no existence check. Two proposals with the same base filename into the same library path silently overwrote the first accepted file. Fix:uniquifyDestPathstat-probes before staging and appends(N)before the extension on collision (up to N=999).StatFileis injectable for tests, defaults toos.Statin production.Test plan
StageFile source-intact guarantee— asserts source survives a staged sync-fault injection (would have caught BUG 1)AcceptProposal — source file not removed on staging or TX failure— assertsRemoveFileis NOT called with the source path on failure (service-level BUG 1 coverage)AcceptProposal — dest-filename collision uniquification— asserts second accept lands atnovel (2).epub, notnovel.epub(BUG 2)UniquifyDestPathunit tests — no collision, first collision, no extension, all-slots-exhausted errorAcceptProposal — uniquify error propagation— 999-slot exhaustion returns an errormake coveragepasses (100% gate — zero uncovered statement blocks)make lintpassesCloses bead bookshelf-t582g.7 and bookshelf-t582g.8 on merge.
Security review — PR #1251 (bd-bookshelf-t582g.7)
Scope: bookdrop accept staging now always copies (no source-move) + uniquifies dest filenames. Adversarial focus on path-traversal, symlink/TOCTOU, data-loss windows, and temp-file perms/cleanup.
[MAJOR] internal/bookdrop/review_service.go:786 — Uniquify→finalize is a check-then-rename TOCTOU that can still overwrite under concurrent accepts
uniquifyDestPathpicks a free name bystatFile(defaultos.Stat), but the actual placement is a much-laterFinalizeFile(tempPath, destPath)=os.Rename(build_extended_deps.go:1806), which on POSIX silently overwrites an existing destination. Two accepts of distinct proposals sharing the same base filename (duplicate uploads, or same-named files in different bookdrop subfolders) can both statnovel (2).epubas free, then both rename — the second clobbers the first file's bytes while both DB rows point at the same path. This is exactly the overwrite the PR sets out to prevent, defeated under concurrency (bookdrop accept runs both from the HTTP handler and the wfengine PENDING_ACCEPT worker path, so simultaneity is reachable). Fix: make finalize refuse to clobber — link/rename with an existence guard (e.g.os.Link+os.Remove(temp), or open destO_CREATE|O_EXCLand copy, orrenameatx/RENAME_NOREPLACEwhere available) and, onEEXIST, re-run uniquify and retry. A pure stat-then-rename cannot be made race-free.[MINOR] internal/bookdrop/stage.go:80 — Staged file (and thus final library file) is fixed at 0600, losing the source file's mode
The removed rename fast-path moved the source inode, preserving its permissions; the always-copy path uses
os.CreateTemp(0600) andos.Renamepreserves that mode into the library. Library files that previously landed group/other-readable now land owner-only. Not a security hole (tighter, not looser), but a behavior regression for multi-process/host setups reading the library. Consider chmod-ing the temp to the source's mode (or a configured library file mode) before finalize.[MINOR] internal/bookdrop/review_service.go:660 — uniquify treats ALL stat errors as "no collision", masking a permission/IO error as a free slot
if _, statErr := statFile(candidate); statErr != nil { return candidate ... }returns the path free on any non-nil error, not justos.ErrNotExist. AnEACCES/EIOon the dest dir is read as "safe to write", deferring the real error to the rename. Comment acknowledges this, and impact is low (rename surfaces the error), but distinguishingerrors.Is(statErr, fs.ErrNotExist)from other errors would fail fast and avoid a misleading "collision-free" decision. (Note: thefilepath.Base(f.FileName) == ".."/ "" traversal edge is NOT reachable here —FileNameisfilepath.Baseof a real on-disk scanned file (service.go:143), which can never be../.; no finding.)Positive confirmations:
nameisfilepath.Base(f.FileName)(no separators) and the(N)suffix inserts no separators, so every candidate stays insidedestDir.os.Renamereplaces a dangling/real symlink atdestPathrather than following it out of the tree;destDiritself is symlink-confined viaconfinedDestDir.f.FilePath(verified by the new source-intact tests).os.CreateTemp(0600, O_EXCL) and cleaned up on every staging error branch.REVIEW VERDICT: 0 blocker, 1 major, 2 minor
af02c436e089bc69a78e[MAJOR] internal/bookdrop/review_service.go:997 — LinkFile nil-default not enforced
The AcceptProposalParams.LinkFile documentation states "Defaults to os.Link when nil"
but line 997 directly assigns linkFn := p.LinkFile without checking for nil. If a
caller creates AcceptProposalParams with LinkFile=nil (violating the code's contract
but matching the documented fallback), finalizeWithRetry will attempt to call a nil
function at line 728, causing a panic. The current wiring in build_extended_deps.go
provides os.Link explicitly, so this doesn't manifest in production, but the code
violates its own documented contract. Fix: add nil-check before finalizeWithRetry
(pattern: if linkFn == nil { linkFn = os.Link }, matching statFn at line 882 and
mkdirAll at line 890).
[MINOR] internal/bookdrop/stage.go:108-110 — chmod timing window
The temp file is created with mode 0o600 (secure), then copied+fsynced, then chmod'd
to 0o644. Between fsync (line 97-101) and chmod (line 111), the file is world-
readable but not yet hard-linked to its final location. Since the temp is in destDir
(a trusted library path), this is acceptable, but it's a subtle security assumption.
Not a correctness bug, just worth documenting. No fix needed, but a clarifying
comment in stage.go (line 108) explaining "os.CreateTemp creates 0o600 for security;
we chmod to 0o644 here so the linked-into-place file is readable by library processes"
would help future reviewers.
REVIEW VERDICT: 0 blocker, 1 major, 1 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
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
Security re-review — PR #1251 (concurrent-accept TOCTOU MAJOR)
Verified
git diff origin/main...origin/bd-bookshelf-t582g.7.The concurrent-accept clobbering MAJOR is CLOSED. Finalize now uses a non-clobbering
os.Link(failsEEXISTwhen the dest name already exists —os.Linkdoes not follow a symlink at the target and never write-through-clobbers) with a bounded re-uniquify retry (finalizeWithRetry, review_service.go:695-740). Two concurrent accepts of same-base-named proposals can no longer both land on the same path: the link is the atomic exclusive claim; the race loser getsEEXISTand advances to the next(N)suffix. Confirmed viainternal/app/build_extended_deps.go:1806wiringLinkFile: os.Linkand the EEXIST-retry test (review_service_test.go:2051-2110).Checklist against the four points:
os.Link+EEXIST is the mutual-exclusion primitive; the winner's file is never overwritten.os.CreateTemp(destDir,...)inside the confineddestDir; every link candidate isfilepath.Join(destDir, base+"(N)"+ext)off afilepath.Base'd name with a numeric suffix. No separators, no.., no follow-through.[MAJOR] internal/bookdrop/review_service.go:998 — DB records the pre-finalize filename; EEXIST-retry path leaves a dangling book_file reference
The TX records
FileName: safeFileName/FileSubPath: fileSubPath(review_service.go:955-957), computed byuniquifyDestPathBEFORE the TX (line 876).finalizeWithRetryruns AFTER the commit (line 998) and, onEEXIST, advances the on-disk path to a different suffix (e.g. DB saysnovel.epubbut the file links tonovel (2).epub— exactly the path the EEXIST-retry test asserts, review_service_test.go:2106-2107). Result in the concurrent-race path: the committedbook_filerow points at a path that does not exist, and the actual file is orphaned on disk (a rescan may re-import it as a duplicate). This is the security fix trading a clobber for a dangling reference — the winner's data is safe (so no security escalation), but it is a real data-integrity regression on the very race this PR fixes. Fix: capture the final linked path/name returned fromfinalizeWithRetryand reconcile thebook_filerow (updatefile_name/file_sub_path) after finalize, or move the link before the TX and record the actually-claimed name. The existing test masks this by stubbingupsertBookFileas a no-op — add an assertion that the storedFileNameequals the finally-linked name in the EEXIST-retry case.[MINOR] internal/bookdrop/review_service.go:997 —
LinkFilehas no nil-default despite the doc claiming oneThe doc comment (review_service.go:272) says "Defaults to os.Link when nil," but
linkFn := p.LinkFileis passed straight intofinalizeWithRetrywith noif linkFn == nil { linkFn = os.Link }guard (contrastStatFile/MkdirAll, which are defaulted). Production wiring passesos.Linkso this is not exploitable, but a nil would panic and the code/doc disagree. Add the nil-default to match the contract.REVIEW VERDICT: 0 blocker, 1 major, 1 minor
Security re-review — PR #1251 (concurrent-accept TOCTOU MAJOR)
Verified
git diff origin/main...origin/bd-bookshelf-t582g.7.The concurrent-accept clobbering MAJOR is CLOSED. Finalize now uses a non-clobbering
os.Link(failsEEXISTwhen the dest name already exists —os.Linkdoes not follow a symlink at the target and never write-through-clobbers) with a bounded re-uniquify retry (finalizeWithRetry, review_service.go:695-740). Two concurrent accepts of same-base-named proposals can no longer both land on the same path: the link is the atomic exclusive claim; the race loser getsEEXISTand advances to the next(N)suffix. Confirmed viainternal/app/build_extended_deps.go:1806wiringLinkFile: os.Linkand the EEXIST-retry test (review_service_test.go:2051-2110).Checklist against the four points:
os.Link+EEXIST is the mutual-exclusion primitive; the winner's file is never overwritten.os.CreateTemp(destDir,...)inside the confineddestDir; every link candidate isfilepath.Join(destDir, base+"(N)"+ext)off afilepath.Base'd name with a numeric suffix. No separators, no.., no follow-through.[MAJOR] internal/bookdrop/review_service.go:998 — DB records the pre-finalize filename; EEXIST-retry path leaves a dangling book_file reference
The TX records
FileName: safeFileName/FileSubPath: fileSubPath(review_service.go:955-957), computed byuniquifyDestPathBEFORE the TX (line 876).finalizeWithRetryruns AFTER the commit (line 998) and, onEEXIST, advances the on-disk path to a different suffix (e.g. DB saysnovel.epubbut the file links tonovel (2).epub— exactly the path the EEXIST-retry test asserts, review_service_test.go:2106-2107). Result in the concurrent-race path: the committedbook_filerow points at a path that does not exist, and the actual file is orphaned on disk (a rescan may re-import it as a duplicate). This is the security fix trading a clobber for a dangling reference — the winner's data is safe (so no security escalation), but it is a real data-integrity regression on the very race this PR fixes. Fix: capture the final linked path/name returned fromfinalizeWithRetryand reconcile thebook_filerow (updatefile_name/file_sub_path) after finalize, or move the link before the TX and record the actually-claimed name. The existing test masks this by stubbingupsertBookFileas a no-op — add an assertion that the storedFileNameequals the finally-linked name in the EEXIST-retry case.[MINOR] internal/bookdrop/review_service.go:997 —
LinkFilehas no nil-default despite the doc claiming oneThe doc comment (review_service.go:272) says "Defaults to os.Link when nil," but
linkFn := p.LinkFileis passed straight intofinalizeWithRetrywith noif linkFn == nil { linkFn = os.Link }guard (contrastStatFile/MkdirAll, which are defaulted). Production wiring passesos.Linkso this is not exploitable, but a nil would panic and the code/doc disagree. Add the nil-default to match the contract.REVIEW VERDICT: 0 blocker, 1 major, 1 minor
[MINOR] internal/bookdrop/review_service.go:721 — claimDestPath loop bound off by 1
The loop
for n := 1; n <= 1001; n++allows 1001 total suffixes (hint + (2) through (1001)), but the comment on line 717 says "(2) … "(1000)"" which suggests only 999 additional attempts after the hint. The code actually allows one extra attempt (to 1001 instead of 1000). This is a nit: the practical collision limit is more than sufficient, and no correctness issue arises from the higher bound. Suggested fix: either change the loop ton <= 1000to match the comment, or update the comment to say "(2) … (1001)" to match the code. This keeps the explicit bound and documentation in sync.All critical safety properties verified:
✓ DB==disk invariant: the committed book_file row records the ACTUALLY-LINKED name (claimedName) from claimDestPath, not the pre-computed stat-probe hint. Test "non-clobbering finalize on TOCTOU race" confirms the DB row matches the file that was actually hard-linked, even when another accept raced and stole the hint path.
✓ Compensation completeness: on claim failure, temp is removed and source left intact; on TX failure AFTER claim succeeds, BOTH the claimed destination AND the temp are unlinked so no orphan file remains. Tests verify both paths.
✓ Source-intact guarantee: StageFile now copy+fsync only (never renames), so the source file survives any failure before commit. The original data-loss bug (rename fast-path destroying the source on temp cleanup) cannot reoccur.
✓ TOCTOU prevention: os.Link is atomic with EEXIST on collision; two concurrent accepts cannot both record different names for the same file.
✓ Status transitions: proposal stays PENDING_REVIEW on any failure (staging, claim, TX), making retries safe and idempotent.
✓ Nil-default guards: both LinkFile (line 899-901) and StatFile (line 871-873) default to os.Link and os.Stat respectively when nil.
✓ chmod applied: temp file chmod to 0o644 before hard-linking, error path tested.
✓ Coverage: 98.9% (uncovered 1.1% is wire.go, standard wiring exclusion).
REVIEW VERDICT: 0 blocker, 0 major, 1 minor
a2cbb88cba4ae9243bac4ae9243bac5c7d2ea3cd