security(update): stage binary replacement at an unpredictable, exclusive path - #751
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the standalone updater’s staging step to prevent an arbitrary-file-overwrite primitive (Issue #742) by removing the predictable <target>.new staging path and ensuring staging files are created exclusively without link/reparse-point traversal.
Changes:
- Generate an unpredictable staging filename (crypto-random suffix) instead of using a fixed
.newname. - Introduce platform-specific
createStagingFileimplementations to guarantee exclusive creation and avoid link/reparse-point following (with defense-in-depth verification on Windows). - Add regression tests covering pre-created hard links/symlinks and a concurrent creation race, and update existing tests to use a deterministic staging suffix hook.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/update/apply.go | Randomizes staging filename and switches staging writes to createStagingFile. |
| internal/update/apply_test.go | Pins staging suffix in tests to deterministically occupy the computed staging path. |
| internal/update/stage_other.go | Adds POSIX `O_CREAT |
| internal/update/stage_other_test.go | Adds non-Windows regression tests for hard link/symlink pre-creation and a concurrency race. |
| internal/update/stage_windows.go | Adds Windows `CreateFile(CREATE_NEW |
| internal/update/stage_windows_test.go | Adds Windows regression tests for hard link/symlink pre-creation and a concurrency race. |
| internal/update/stage_test_helpers_test.go | Adds test helper to stub the random staging suffix deterministically. |
Comments suppressed due to low confidence (1)
internal/update/apply.go:233
- In installBinary, the staged file is only scheduled for removal after copyFile succeeds. If copyFile creates the staging file and then fails (e.g., disk full / short write), the partially-written random staging file will be left behind in the install directory. Consider deferring the cleanup immediately after stagingFilePath succeeds so failures during copyFile are also cleaned up.
if err := copyFile(sourcePath, stagedPath); err != nil {
return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err)
}
defer func() {
_ = os.Remove(stagedPath)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughThe updater replaces predictable pathname-based staging with exclusive, handle-bound staging objects. POSIX and Windows promotion paths verify object identity, preserve recovery state, classify possible tampering, and add coverage for link, race, substitution, and failure scenarios. ChangesSecure updater staging
Estimated code review effort: 5 (Critical) | ~90 minutes Sequence Diagram(s)sequenceDiagram
participant installBinary
participant PlatformStaging
participant TargetBinary
participant Recovery
installBinary->>PlatformStaging: Create and populate verified staged object
PlatformStaging->>TargetBinary: Promote using handle-bound operation
PlatformStaging->>TargetBinary: Verify promoted object identity
PlatformStaging->>Recovery: Restore or preserve original after failure
Recovery-->>installBinary: Return recovery or tampering status
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/update/apply.go (1)
225-234: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winMove the
deferblock beforecopyFileto prevent leaking temporary files.If
copyFilefails (e.g., due to a full disk or an interrupted read),installBinaryreturns early and the partially written staging file is never removed, causing a permanent resource leak on every failed update.Moving the
deferblock immediately afterstagedPathgeneration guarantees cleanup across all error paths. This is entirely safe: ifreplaceBinarysuccessfully renames the file later,os.Removewill return a silentos.ErrNotExistthat safely gets ignored by the blank identifier.🛠 Proposed fix
stagedPath, err := stagingFilePath(targetPath) if err != nil { return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err) } + defer func() { + _ = os.Remove(stagedPath) + }() if err := copyFile(sourcePath, stagedPath); err != nil { return fmt.Errorf("stage %s: %w", filepath.Base(targetPath), err) } - defer func() { - _ = os.Remove(stagedPath) - }() if err := replaceBinary(targetPath, stagedPath); err != nil { return fmt.Errorf("install %s: %w", filepath.Base(targetPath), err) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/apply.go` around lines 225 - 234, Move the cleanup defer in installBinary to immediately after successful stagingFilePath generation and before copyFile, so partially created staging files are removed when copying fails. Keep the existing os.Remove callback and ignored error behavior unchanged.
🧹 Nitpick comments (1)
internal/update/stage_other_test.go (1)
17-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsolidate duplicated test files into a single cross-platform
stage_test.go. Both test files contain virtually identical test suites. As per coding guidelines, prefer one cross-platform function with small conditional checks over duplicated helpers when behavior can remain unified.
internal/update/stage_other_test.go#L17-L129: merge this test suite into a new unifiedstage_test.go.internal/update/stage_windows_test.go#L17-L130: merge this identical logic into the unified file, retaining the single conditionalif runtime.GOOS == "windows"for thet.Skipf("symlink unavailable...")skip logic.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/stage_other_test.go` around lines 17 - 129, Merge the duplicated test suites from internal/update/stage_other_test.go lines 17-129 and internal/update/stage_windows_test.go lines 17-130 into a single cross-platform internal/update/stage_test.go. Preserve the existing tests for createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race coverage; retain one runtime.GOOS == "windows" conditional for the symlink-unavailable t.Skipf behavior, and remove the duplicated platform-specific test files.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@internal/update/apply.go`:
- Around line 225-234: Move the cleanup defer in installBinary to immediately
after successful stagingFilePath generation and before copyFile, so partially
created staging files are removed when copying fails. Keep the existing
os.Remove callback and ignored error behavior unchanged.
---
Nitpick comments:
In `@internal/update/stage_other_test.go`:
- Around line 17-129: Merge the duplicated test suites from
internal/update/stage_other_test.go lines 17-129 and
internal/update/stage_windows_test.go lines 17-130 into a single cross-platform
internal/update/stage_test.go. Preserve the existing tests for
createStagingFile, including hard-link, symlink, fresh-path, and concurrent-race
coverage; retain one runtime.GOOS == "windows" conditional for the
symlink-unavailable t.Skipf behavior, and remove the duplicated
platform-specific test files.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 754ad950-6559-4c37-bbdf-f873fe80c35e
📒 Files selected for processing (7)
internal/update/apply.gointernal/update/apply_test.gointernal/update/stage_other.gointernal/update/stage_other_test.gointernal/update/stage_test_helpers_test.gointernal/update/stage_windows.gointernal/update/stage_windows_test.go
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approve. I went through this on the security question and could not find a bypass: the verified binary reaches disk only through an exclusively-created, no-follow handle at an unpredictable path, then gets renamed into place.
Checked on head f62c4fc:
- POSIX (stage_other.go): createStagingFile is O_CREATE|O_EXCL|O_WRONLY, so it fails on any pre-existing name including a dangling symlink, without following it. The suffix is 16 bytes from crypto/rand, not math/rand or time-derived.
- Windows (stage_windows.go): CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT operates on the reparse point itself and fails on a planted symlink or junction, and verifyFreshRegularFile then rejects reparse point / directory / NumberOfLinks>1 via GetFileInformationByHandle before any bytes are written. Belt and suspenders.
- Ordering is right: the checksum is verified before the binary is moved into the live target, and copyFile writes through the exact handle from createStagingFile with no reopen-by-path in between. No residual predictable
.newname remains. - Built and ran internal/update locally: gofmt, vet and build clean, tests pass including the hardlink/symlink refusal and checksum-mismatch cases. CI green.
One non-blocking nit worth a quick follow-up: installBinary registers defer os.Remove(stagedPath) after the copyFile error check (apply.go ~229-234), and copyFile only closes dest on an io.Copy failure, never removes it. So a mid-write copy failure (short write, full disk) leaks the partial staging file in the install directory. Not a security issue (128-bit random O_EXCL name, contents are a partial copy of the already-public binary), but easy to close by moving the removal defer into copyFile right after createStagingFile succeeds, plus a test for the mid-write path. Both bots flagged the same thing.
LGTM.
gnanam1990
left a comment
There was a problem hiding this comment.
APPROVE — the core hardening is correct and well-tested; the only residue is orphaned staging files, which is a pre-existing cleanup gap the randomization mildly amplifies (minor), plus a test-coverage nit.
The substantive fix for #742 is solid: createStagingFile opens with O_CREATE|O_EXCL|O_WRONLY (stage_other.go), so a pre-created hard link or symlink at the staging path fails EEXIST instead of being truncated through, and the Windows path adds CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus the verifyFreshRegularFile link-count/reparse checks. I reproduced the base arbitrary-file-truncate primitive (fixed <target>.new + O_TRUNC opened through a pre-created hard link) and confirmed the new code refuses it. The regression tests in stage_other_test.go genuinely exercise both the hard-link and symlink variants and assert the victim is untouched. Good, well-commented work.
[Minor] Orphaned <binary>.<hex>.new staging files are never reclaimed and now accumulate — PR-introduced amplification of a pre-existing gap
internal/update/apply.go:229-234
Two paths leave a staged file behind, and no code ever removes .new files (CleanupStaleBinary only handles <binary>.old, and on non-Windows it is a no-op — replace_other.go:19 / replace_windows.go:56):
- Copy failure (pre-existing ordering, PR-introduced amplification): the
defer os.Remove(stagedPath)is registered at line 232, aftercopyFileat line 229. IfcopyFilefails (ENOSPC on the install volume, unreadable source),installBinaryreturns at line 230 before the defer is ever registered, so the partial file survives. On base this ordering existed too, but the fixed<target>.newname meant the next attempt truncated and reused the same orphan (self-healing). With the PR's random suffix, each retry on a persistently full disk writes a fresh<binary>.<hex>.new, so partial-binary-sized files pile up in the install directory. - Hard crash mid-window (PR-introduced): if the process is SIGKILLed / loses power after
copyFilesucceeds (line 229) but beforereplaceBinaryrenames (line 235), the deferred remove never runs. On base the single fixed orphan was reused by the next upgrade; now each crashed upgrade leaves a uniquely-named orphan that nothing collects.
Neither is a security or correctness bug — the swap still works and disk pressure is the only cost — hence Minor. Two independent fixes close it: (a) register the staged-file cleanup immediately after stagingFilePath succeeds (or have copyFile/createStagingFile remove its own dest on a non-nil return) so the copy-failure exit path is covered; and (b) on startup, best-effort glob and remove stale <binary>.*.new files in the target directory, analogous to CleanupStaleBinary's .old handling, to sweep crash leftovers.
[Nit] The "unpredictable path" half of the fix has no mutation-sensitive test — PR-introduced
internal/update/apply.go:260
I reverted stagingFilePath to a fixed filepath.Base(targetPath) + ".new" (dropping the random suffix) while keeping O_EXCL, and go test -count=1 ./internal/update/ still passed. The new tests call createStagingFile directly with hand-built paths, and TestApplyStandaloneUpdateWarnsWhenHelperRefreshFails computes the occupied path via stagingFilePath itself, so both pass regardless of whether the suffix is random. The randomness (the title's stated defense-in-depth against pre-creation/guessing) can therefore be removed with zero test signal. Not a security hole — removing O_EXCL instead fails 3 tests, confirming that check is the real guard — but a small unit test asserting stagingFilePath(target) places the file in filepath.Dir(target), contains the base name, and returns two different paths on successive calls with the real randomStagingSuffix would lock in the property.
Tests: gofmt, go vet (incl. GOOS=windows), and go test -race -count=1 ./internal/update/... all pass on the PR head; no PR-attributable failures, no environmental interference in this package.
Merge is kevin's call per the program gate.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Keep the staged file bound to its verified handle through replacement
internal/update/apply.go:229-235
copyFilecloses the exclusively-created staging handle beforereplaceBinaryconsumesstagedPathby name. Under #742's documented model—where the lower-privileged principal can create or replace sibling entries—it can observe the new randomized filename, replace that entry after the close, and have the updater promote its replacement instead of the verified bytes. On POSIX,replaceBinaryfirst follows the substituted path inos.Chmodand then renames it into the executable path; Windows likewise performs the final rename by pathname after the handle has been closed. Randomizing the name and usingO_EXCLonly prevent pre-creation, not this live handoff race, so the updater can still install attacker-controlled code. Retain and validate object identity through the final swap (or use a handle-bound replacement primitive), and add an end-to-end substitution-race regression test. -
[P2] Clean up a staged file when copying it fails
internal/update/apply.go:229-234
The removal defer is registered only aftercopyFilereturns successfully. Ifio.CopyorClosefails aftercreateStagingFilehas created the destination (for example, on ENOSPC), that partial file is left behind. This change makes each retry use a fresh randomized*.newname, whereas the prior fixed name was reused, and startup cleanup only removes.oldfiles. Repeated failed upgrades can therefore accumulate release-sized artifacts until the installation volume is exhausted. Register cleanup before copying (and clean crash leftovers) so an unsuccessful update does not consume space permanently.
fa02593
…pathname Addresses the two open findings on Gitlawb#751. [P1] The staged file is no longer promoted by pathname. Randomizing the name and creating it exclusively stops PRE-creation, but under Gitlawb#742's model — a lower-privileged principal that can write in the installation directory — the name can be observed after the fact and the entry replaced between the write and the swap, so the updater would install the substituted file. installBinary now keeps the creating handle open and promotes through it: - Windows renames through the handle itself (SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so there is no second pathname lookup to win. replaceBinary is gone; promote owns the aside-rename of the running binary and its restore-on-failure retry. - POSIX has no rename-by-descriptor, so staging moves into a private directory created next to the target by os.MkdirTemp (mode 0700, random name, created exclusively). An attacker who can write in the installation directory cannot create, replace, or list entries inside it. promote additionally sets the executable bit through the handle (os.Chmod would re-resolve the path) and fails closed if the entry ever stops naming the object it wrote. [P2] Staging cleanup now covers every failure path: the removal defer is registered before the copy, and stageBinary discards the object if the copy fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a release-sized file that the next attempt will not reuse. CleanupStaleBinary also sweeps leftovers from a hard crash — staging files on Windows, staging directories on POSIX, where it used to be a no-op — skipping anything younger than an hour so a concurrent update is never disturbed. Tests: substitution-race regression per platform (Windows asserts the verified bytes are installed despite a replaced staging entry; POSIX asserts the private 0700 directory and that promote refuses a substituted entry), success controls, cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test for helper refresh now uses a stageBinary seam, because the staging location can no longer be occupied from outside — which is the point of the fix.
|
Pushed [P1] Keep the staged file bound to its verified handle through replacement — this was a real hole and the fix is structural:
[P2] Clean up a staged file when copying it fails (also raised by @Vasanthdev2004, @gnanam1990, CodeRabbit and Copilot) — the removal defer is registered before the copy, and Tests
Validation — honest about the platform limits of this host (Windows, Go 1.26.5):
@jatmn @Vasanthdev2004 @gnanam1990 — this is a bigger change than the previous round (the promotion primitive itself changed per platform), so a fresh look at @coderabbitai review |
|
✅ Action performedReview finished.
|
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Fix the Windows handle-promotion path before merging
internal/update/stage_windows.go:106-118
The required Windows job is red on the current head:TestPromoteInstallsTheStagedObjectNotTheStagedPathgets a nil result fromstaged.promote, but after closing the handlezero.exedoes not exist. At that point the old executable has already been moved to.oldandpromotedsuppresses deferred removal, so an update can report success while stranding the user without the executable. Correct theFILE_RENAME_INFOconstruction/handle rename behavior and keep an end-to-end Windows assertion that the verified binary is reachable attargetPath. -
[P1] Keep the POSIX staging directory bound through the final rename
internal/update/stage_other.go:65-69
A0700directory protects its contents, but not its directory entry: under the issue's writable-install-directory threat model, an attacker can rename the.zero-stage-*directory from its writable parent afterverifyStagedIdentityreturns, recreate that directory with an attacker-controlled file at the same basename, and win the gap beforeos.Rename(staged.path, targetPath). The final path-based rename then installs attacker bytes. The current test only substitutes the child entry before the check, so it misses the ancestor replacement race; use a promotion primitive that remains safe when the parent namespace can change and cover this race end to end. -
[P1] Do not leave a raced target executable in place on Windows
internal/update/stage_windows.go:101-114
After the updater renames the running binary to.old, a writer of the installation directory can create a malicioustargetPath.renameFileByHandledeliberately usesReplaceIfExists=false, so promotion fails; the restore also fails because that attacker file occupiestargetPath. The function returns an error but leaves the attacker-controlled file at the executable path for the next launch, with the original only at.old. Restore/replace must safely handle this namespace race rather than treating it as a harmless failed update. -
[P2] Restrict stale cleanup to artifacts the updater can prove it owns
internal/update/stage_other.go:114-122
internal/update/replace_windows.go:55-64
Cleanup runs on everyApply, including the no-update path, but recognizes arbitrary old sibling entries by loose names: every.zero-stage-*directory is recursively removed on POSIX and every<binary>.*.newfile is removed on Windows. Those are broader than the generated artifact formats and can match legitimate user data such as.zero-stage-backuporzero.exe.release-notes.new. The pathname is also re-resolved after the age check, allowing a writable-parent actor to swap a checked directory beforeRemoveAll. Validate exact owned artifacts and avoid recursive deletion of untrusted pathnames before deleting anything. -
[P3] Do not age out an update that is still copying
internal/update/stage_other.go:118-122
The POSIX cleanup uses the staging directory's mtime as its liveness signal, butcopyFromonly writes the child file and never refreshes that timestamp. If a copy is slow or stalled for over an hour, a secondzero upgraderemoves the live staging directory; the first updater then fails identity verification or promotion even though it is still active. Track live staging independently, refresh a liveness marker, or avoid age-based removal of active directories.
Address jatmn's review findings on PR Gitlawb#751: - Windows promote() now verifies targetPath is actually reachable after a reported-successful handle rename before trusting it. Some Windows versions have been observed accepting SetFileInformationByHandle against a handle whose directory entry was substituted out from under it without the object actually moving, which let promote report success while targetPath was left missing entirely. renameFileByHandle is now a package var so this is covered by a deterministic regression test rather than relying on reproducing the exact trigger condition. - When a promotion failure's restore-to-.old also fails (a writable-parent attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't get past), that combination is now wrapped in ErrTargetPossiblyTampered instead of reading like an ordinary failed update, with a best-effort MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still recover the original at next boot. - POSIX promote() now binds its final rename to a directory descriptor opened when the staging directory is created (unix.Renameat), not a pathname. The 0700 staging directory protects its contents from a writable-parent principal, but not its own directory entry — that principal could rename the staging directory aside and recreate a look-alike with an attacker file at the same basename in the gap between the identity check and the rename, which a plain os.Rename would silently follow. - Stale-cleanup on both platforms now matches the exact generated artifact shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix + 32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a user's own similarly-named file or directory is never swept up. Both also re-check identity immediately before deleting to shrink the window a writable-parent principal could swap the checked path in. - copyFrom now copies in chunks and refreshes the POSIX staging directory's mtime between them, since writing into an already-created file never touches the parent directory's own mtime — a large or slow copy could previously look abandoned to a concurrent update's cleanup sweep while still in progress. Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet checks for darwin/linux-arm64. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (5)
internal/update/replace_windows.go (1)
98-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCleanup-sweep logic duplicates the POSIX implementation's structure.
Same scan→age-filter→identity-recheck→delete skeleton as
removeStaleStagingLeftoversininternal/update/stage_other.go, only the name predicate (isGeneratedStagingFileNamevs.isGeneratedStagingDirName) and the deletion call (os.Removevs.os.RemoveAll) differ. See the consolidated comment.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/replace_windows.go` around lines 98 - 129, Consolidate the duplicated cleanup-sweep logic shared by removeStaleStagingLeftovers and the POSIX implementation in stage_other.go into a reusable helper. Parameterize the name predicate and deletion operation so file cleanup uses isGeneratedStagingFileName with os.Remove, while directory cleanup uses isGeneratedStagingDirName with os.RemoveAll; preserve the existing age and identity-recheck behavior.Source: Coding guidelines
internal/update/stage_other.go (1)
152-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCleanup-sweep logic is nearly identical to the Windows counterpart.
This scan→age-filter→identity-recheck→delete loop mirrors
removeStaleStagingLeftoversininternal/update/replace_windows.goalmost line for line, differing only in the name-matching predicate and whether the target is a directory (RemoveAll) or a file (Remove). See the consolidated comment for a suggested shared helper.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/stage_other.go` around lines 152 - 184, Consolidate the duplicated scan, age-filter, identity-recheck, and deletion logic shared by removeStaleStagingLeftovers and its Windows counterpart into a reusable helper. Parameterize the helper for the existing name predicate and directory/file deletion behavior, then update both callers while preserving their current cleanup semantics.Source: Coding guidelines
internal/update/stage_promote_windows_test.go (1)
172-184: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate
assertNoStagingLeftovershelper vs. the POSIX test file.This Windows-only helper (matching
.newsuffix) duplicates the intent ofassertNoStagingLeftoversinstage_promote_other_test.go(which also matches the generated staging-directory prefix). See consolidated comment for the proposed unification. As per coding guidelines, "Prefer one cross-platform function with small conditional checks over duplicated platform-specific helpers when behavior can remain unified."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/stage_promote_windows_test.go` around lines 172 - 184, Unify the Windows-only assertNoStagingLeftovers helper with the cross-platform helper in stage_promote_other_test.go, removing the duplicate definition from the Windows test file. Preserve checks for both the generated staging-directory prefix and the Windows “.new” suffix using small platform-appropriate conditions within the shared helper.Source: Coding guidelines
internal/update/stage_promote_other_test.go (2)
134-176: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest only checks the end-state, not "during copy" behavior it claims to regress-test.
The test forces small chunks and asserts the staging directory's mtime is fresh after
copyFromreturns. An implementation that only refreshes the mtime once at the very end of the copy (rather than periodically "as it goes," per the doc comment's stated intent) would pass this assertion too — yet that implementation would not actually fix the concurrent-sweep-sees-stale-mtime-mid-copy race this test is meant to guard against. Consider asserting freshness partway through the copy (e.g., wrap the source reader to pause/check after the first chunk) to actually exercise the "during copy" guarantee.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/stage_promote_other_test.go` around lines 134 - 176, Strengthen TestCopyFromRefreshesStagingLivenessDuringCopy to verify the staging directory mtime during copy, not only after staged.copyFrom returns. Wrap or control the source read so the test pauses after the first chunk, checks that staged.dir has a mtime newer than stale, then allows copying to finish; retain the final completion assertion and cleanup behavior.
282-294: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate
assertNoStagingLeftovershelper vs. the Windows test file.This helper (matching generated staging prefix or
.newsuffix) duplicatesassertNoStagingLeftoversinstage_promote_windows_test.go(which matches.newsuffix only). Since checking both patterns is harmless on either platform (the POSIX-only prefix pattern will simply never match on Windows and vice versa), this looks like a good candidate to unify into a single helper in the sharedstage_test_helpers_test.gorather than maintaining two near-identical, platform-forked copies that can drift. See consolidated comment for details. As per coding guidelines, "Prefer one cross-platform function with small conditional checks over duplicated platform-specific helpers when behavior can remain unified."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/update/stage_promote_other_test.go` around lines 282 - 294, Move the shared assertNoStagingLeftovers helper into stage_test_helpers_test.go and remove the duplicate definitions from the POSIX and Windows test files. Preserve both checks in the unified helper: stagingDirPrefix matches and the .new suffix matches, so leftover detection remains consistent across platforms.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@internal/update/replace_windows.go`:
- Around line 98-129: Consolidate the duplicated cleanup-sweep logic shared by
removeStaleStagingLeftovers and the POSIX implementation in stage_other.go into
a reusable helper. Parameterize the name predicate and deletion operation so
file cleanup uses isGeneratedStagingFileName with os.Remove, while directory
cleanup uses isGeneratedStagingDirName with os.RemoveAll; preserve the existing
age and identity-recheck behavior.
In `@internal/update/stage_other.go`:
- Around line 152-184: Consolidate the duplicated scan, age-filter,
identity-recheck, and deletion logic shared by removeStaleStagingLeftovers and
its Windows counterpart into a reusable helper. Parameterize the helper for the
existing name predicate and directory/file deletion behavior, then update both
callers while preserving their current cleanup semantics.
In `@internal/update/stage_promote_other_test.go`:
- Around line 134-176: Strengthen TestCopyFromRefreshesStagingLivenessDuringCopy
to verify the staging directory mtime during copy, not only after
staged.copyFrom returns. Wrap or control the source read so the test pauses
after the first chunk, checks that staged.dir has a mtime newer than stale, then
allows copying to finish; retain the final completion assertion and cleanup
behavior.
- Around line 282-294: Move the shared assertNoStagingLeftovers helper into
stage_test_helpers_test.go and remove the duplicate definitions from the POSIX
and Windows test files. Preserve both checks in the unified helper:
stagingDirPrefix matches and the .new suffix matches, so leftover detection
remains consistent across platforms.
In `@internal/update/stage_promote_windows_test.go`:
- Around line 172-184: Unify the Windows-only assertNoStagingLeftovers helper
with the cross-platform helper in stage_promote_other_test.go, removing the
duplicate definition from the Windows test file. Preserve checks for both the
generated staging-directory prefix and the Windows “.new” suffix using small
platform-appropriate conditions within the shared helper.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 1c330dc9-d61a-49c6-9d09-7c211cf38cc5
📒 Files selected for processing (7)
internal/update/apply.gointernal/update/replace_windows.gointernal/update/replace_windows_test.gointernal/update/stage_other.gointernal/update/stage_promote_other_test.gointernal/update/stage_promote_windows_test.gointernal/update/stage_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/update/apply.go
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P1] Restore gofmt cleanliness before merging
internal/update/replace_windows_test.go:90
The required Ubuntu smoke job currently fails its formatting step on this file, so vet, tests, build, and smoke are all skipped. Run gofmt and commit the resulting alignment change. -
[P1] Bind the POSIX staging directory before an attacker can replace it
internal/update/stage_other.go:41
MkdirTempcreates the private directory, but the code subsequently opens it again by pathname. A principal that can write the installation directory can rename that entry and recreate it beforeos.Open, makingdirHandlerefer to an attacker-writable directory. The attacker can then replace the child afterverifyStagedIdentityand beforeRenameat, causing the verified updater to install attacker bytes. Acquire/validate the created directory without this pathname handoff and cover that interleaving. -
[P1] Do not recursively remove an attacker-substituted staging directory
internal/update/apply.go:337
The POSIX promotion deliberately tolerates the staging directory being renamed aside, but deferreddiscardlater callsRemoveAll(staged.dir)through the old pathname. An attacker can replace that pathname with a chosen directory; the elevated updater then recursively deletes the replacement. The stale-cleanup path instage_other.go:177-182has the same Lstat-to-RemoveAll race. Keep deletion bound to the original object or fail closed rather than recursively deleting a mutable pathname; the ancestor-replacement test should assert the impostor survives deferred cleanup. -
[P1] Verify that the promoted Windows target is the staged object
internal/update/stage_windows.go:149
This check accepts any non-directory attargetPath. In the anomalous success case it is meant to handle—handle rename reports success but does not link the staged file—an attacker with write access to the installation directory can create a regular malicious target beforeos.Stat. The check then passes and the update is reported successful with attacker bytes. Verify object identity, not only target existence. -
[P1] Do not schedule reboot recovery from a mutable source pathname
internal/update/replace_windows.go:66
After an immediate restore failure, this queues<target>.oldto replace the executable at reboot. Under the stated writable-directory threat model, an attacker can replace that source entry before reboot, turning the recovery action into a delayed installation of attacker-selected bytes. Surface the recovery failure or use a recovery source outside the attacker-controlled directory. -
[P2] Do not infer cleanup ownership from a public filename shape
internal/update/stage_other.go:163
Any old directory named.zero-stage-<1..10 digits>is treated as an updater artifact and recursively removed; no provenance distinguishes it from user data. Windows makes the same assumption for any exact<binary>.<32 hex>.newfile inreplace_windows.go:109. Exact pattern matching reduces accidental collisions but does not establish ownership, so preserve non-verifiable entries or introduce durable, unforgeable ownership metadata with an appropriate migration story.
|
Addressed the requested changes in
Validation completed:
Environment limitations: the repository-wide test run reaches an unrelated CLI completion test that requires an installed WSL distribution, and |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/update/stage_other.go`:
- Around line 20-63: Update the openAndVerifyStagingDirectory failure path in
createStagedBinary to clean up the newly created staging directory before
returning. Perform best-effort Lstat and os.SameFile validation against
createdInfo, and remove the directory only when it is still the directory
created by this call, preserving the existing error return and avoiding removal
of a substituted path.
In `@internal/update/stage_windows.go`:
- Around line 136-177: Update verifyPromotedTarget to resolve targetPath through
Windows reparse points before comparing it with the fully resolved path returned
by GetFinalPathNameByHandle. Replace the filepath.Abs-only comparison with an
appropriate Windows path-resolution approach, while preserving UNC normalization
and case-insensitive cleaned-path comparison.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 02939d71-ea84-43b1-b619-5ba074d014f3
📒 Files selected for processing (7)
internal/update/apply.gointernal/update/replace_windows.gointernal/update/replace_windows_test.gointernal/update/stage_other.gointernal/update/stage_promote_other_test.gointernal/update/stage_promote_windows_test.gointernal/update/stage_windows.go
🚧 Files skipped from review as they are similar to previous changes (1)
- internal/update/apply.go
|
Heads-up before this gets another review pass: CI is red across the board on 33ab509, not just one leg. Smoke fails on ubuntu, macos and windows, plus Zero Review. That looks like a genuine build or test break rather than the Windows provider-command flake, since all three platforms agree. I held off reviewing the substance until that is green, since the diff will probably move. Ping me when CI is passing and I will go through the staging changes properly, including the exclusive-create path and the leak-on-copy-failure item from my earlier review. |
…pathname Addresses the two open findings on Gitlawb#751. [P1] The staged file is no longer promoted by pathname. Randomizing the name and creating it exclusively stops PRE-creation, but under Gitlawb#742's model — a lower-privileged principal that can write in the installation directory — the name can be observed after the fact and the entry replaced between the write and the swap, so the updater would install the substituted file. installBinary now keeps the creating handle open and promotes through it: - Windows renames through the handle itself (SetFileInformationByHandle/FileRenameInfo, hence the added DELETE access), so there is no second pathname lookup to win. replaceBinary is gone; promote owns the aside-rename of the running binary and its restore-on-failure retry. - POSIX has no rename-by-descriptor, so staging moves into a private directory created next to the target by os.MkdirTemp (mode 0700, random name, created exclusively). An attacker who can write in the installation directory cannot create, replace, or list entries inside it. promote additionally sets the executable bit through the handle (os.Chmod would re-resolve the path) and fails closed if the entry ever stops naming the object it wrote. [P2] Staging cleanup now covers every failure path: the removal defer is registered before the copy, and stageBinary discards the object if the copy fails, so a mid-write error (ENOSPC, unreadable source) no longer leaks a release-sized file that the next attempt will not reuse. CleanupStaleBinary also sweeps leftovers from a hard crash — staging files on Windows, staging directories on POSIX, where it used to be a no-op — skipping anything younger than an hour so a concurrent update is never disturbed. Tests: substitution-race regression per platform (Windows asserts the verified bytes are installed despite a replaced staging entry; POSIX asserts the private 0700 directory and that promote refuses a substituted entry), success controls, cleanup-on-failure, and the crash-leftover sweep. The forced-staging-failure test for helper refresh now uses a stageBinary seam, because the staging location can no longer be occupied from outside — which is the point of the fix.
Address jatmn's review findings on PR Gitlawb#751: - Windows promote() now verifies targetPath is actually reachable after a reported-successful handle rename before trusting it. Some Windows versions have been observed accepting SetFileInformationByHandle against a handle whose directory entry was substituted out from under it without the object actually moving, which let promote report success while targetPath was left missing entirely. renameFileByHandle is now a package var so this is covered by a deterministic regression test rather than relying on reproducing the exact trigger condition. - When a promotion failure's restore-to-.old also fails (a writable-parent attacker occupying targetPath with a lock MOVEFILE_REPLACE_EXISTING can't get past), that combination is now wrapped in ErrTargetPossiblyTampered instead of reading like an ordinary failed update, with a best-effort MOVEFILE_DELAY_UNTIL_REBOOT fallback so an admin-context updater can still recover the original at next boot. - POSIX promote() now binds its final rename to a directory descriptor opened when the staging directory is created (unix.Renameat), not a pathname. The 0700 staging directory protects its contents from a writable-parent principal, but not its own directory entry — that principal could rename the staging directory aside and recreate a look-alike with an attacker file at the same basename in the gap between the identity check and the rename, which a plain os.Rename would silently follow. - Stale-cleanup on both platforms now matches the exact generated artifact shape (POSIX: prefix + os.MkdirTemp's all-digit suffix; Windows: prefix + 32 lowercase hex chars + ".new") instead of a loose prefix/suffix, so a user's own similarly-named file or directory is never swept up. Both also re-check identity immediately before deleting to shrink the window a writable-parent principal could swap the checked path in. - copyFrom now copies in chunks and refreshes the POSIX staging directory's mtime between them, since writing into an already-created file never touches the parent directory's own mtime — a large or slow copy could previously look abandoned to a concurrent update's cleanup sweep while still in progress. Verified on real Windows and Linux (via WSL), plus cross-compiled build/vet checks for darwin/linux-arm64. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bind POSIX staging creation and both rename endpoints to directory descriptors, clean only identity-matched empty directories, and verify Windows promotion by file identity rather than path spelling. Preserve unverifiable crash leftovers and add adversarial cleanup, hard-link, and reparse-ancestor coverage. Amp-Thread-ID: https://ampcode.com/threads/T-019fa019-27da-72ec-8e6d-5d43f127b6a1 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Two error-path follow-ups from the Gitlawb#742 staging review. When a promotion fails and restoring the original also fails, the error tells the operator their original is preserved at <binary>.old — and then the next Apply deleted it, because a present target read as proof the copy was stale. What occupies the target in exactly that case is the bytes the updater could not verify, so cleanup was erasing the one it could. A failed restore now marks the copy, cleanup honors the mark, and a successful promotion clears it. The POSIX staging flow could also leave an empty .zero-stage-* directory behind when the stat immediately after mkdirat failed. Nothing knows that name yet, so it would have stayed for good; it is now removed best-effort with rmdir semantics, which cannot touch anything but the empty directory just created. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…attempt The marker added in d9523ba kept cleanup from deleting <target>.old, but four paths still undermined the promise it was meant to make. Promotion destroyed the copy it was preserving. Skipping the pre-rename cleanup was not enough: os.Rename uses MOVEFILE_REPLACE_EXISTING on Windows, so renaming the running binary aside overwrote the last verified copy with the unverified bytes the earlier failure left at the target — and a promotion that then failed could only move those unverified bytes back. Promotion now refuses while the tamper state is unresolved. Only the operator can say whether the file at the target is theirs, so the error names both moves that end it: restore the copy, or delete the marker to accept what is installed. The marker itself was a predictable link-following truncate write. Its path is fixed, so a writer in the install directory could pre-create it as a hard link or reparse point and have the elevated updater write through it. It is now created with the same CREATE_NEW + FILE_FLAG_OPEN_REPARSE_POINT + fresh-regular- file check as a staging file, and never truncates; an existing name is treated as already-marked rather than an object to open. A marker that could not be written left the promise unqualified while the next run's cleanup deleted exactly the file it named. That failure now rides in the error, telling the operator to copy it somewhere safe now. Helper refreshes downgraded tampering to a warning. An ordinary helper failure still warns — a stale helper is better than a failed update — but a helper whose path may hold unverified content fails the apply, because helpers are resolved from the install directory and executed by the sandbox runner, and reporting Applied: true would hand the operator a success while a sibling executable is suspect. ErrTargetPossiblyTampered moved to apply.go so callers on every platform can test for it without build tags. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…shed Surfacing a failed marker write told the operator to hurry; it did not stop the next run from deleting the file they were being told to save. When no marker can be established, the copy is now moved to an unpredictable sibling name that routine cleanup never touches — CleanupStaleBinary only ever removes the exact "<target>.old" — and the error names where it went. Telling the operator to act now remains the fallback for when even that move fails. The marker check is conservative in the same direction: only a definite "not there" allows the copy to be deleted. An Lstat that fails for any other reason leaves the question open, and deleting is irreversible while keeping costs one stale file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019faf74-99d4-75cf-ac7a-661c308240ef Co-authored-by: Amp <amp@ampcode.com>
A failed second-or-later update marks the randomized aside it used, but promotion only consulted the canonical <target>.old marker, so a retry proceeded over an unverified target while the verified copy sat marked on a path the refusal logic did not watch. Promotion now enumerates every recovery candidate — canonical and randomized asides — and refuses while any of them is marked, naming each recovery path and its marker. The missing-target refusal had the same single-path blind spot: with a stale canonical .old from an earlier successful update plus the aside from an interrupted attempt, it named only the canonical path, which can be the wrong binary. It now names the single actual candidate or refuses the ambiguous layout outright until the operator resolves it. A failed marker write could also leave a partial .keep behind while the recovery copy was relocated elsewhere; later refusals then named a location that no longer held the verified bytes. markOldBinaryPreserved now removes the entry it created before reporting a write/close failure, and conservatively reports success when that removal (or the state check) fails so oldPath stays authoritative. keepUnmarkedRecoveryCopy is handle-bound end to end: it pins the recovery copy with a no-delete-sharing, no-reparse open, verifies it is a single-link regular file, renames through the handle with ReplaceIfExists false, and confirms identity at the destination, failing closed with ErrTargetPossiblyTampered on any race. Finally, the error for an unestablishable marker still promised that a later update would otherwise remove the copy, but CleanupStaleBinary is now a no-op; the operator is told that a manual copy is required.
…stitution TestPromoteInstallsTheStagedObjectNotTheStagedPath deletes the staging file's only directory entry and recreates it as an "attacker" file, then required promote to succeed with the verified bytes installed. CI's windows-latest runner permits that delete (this workstation's exclusive share mode blocks it, so the path went unexercised here); on that runner, renaming the now fully-unlinked staging handle back into existence is not something every Windows build honors, so verifyPromotedTarget's post- rename identity check finds nothing at targetPath, promote fails, and restoreOriginalBinary moves the pre-update binary back — a safe, fail- closed outcome, not a security regression. The test now accepts either outcome after a real substitution: promote succeeding with the verified bytes, or promote failing as long as the attacker's substituted bytes were never installed. Any promote failure that occurs without substitution having actually happened still fails the test outright.
…itively keepUnmarkedRecoveryCopy discarded the destination path when the rename to it succeeded but the post-rename identity verification failed, leaving restoreOriginalBinary's error telling the operator to save oldPath after it had already been vacated. Return the attempted kept path alongside the verification error so the caller can still name it. existingRecoveryPaths compared "<target>.old" filenames case-sensitively, but NTFS is case-insensitive/case-preserving, so a recovery or marker file spelled e.g. "zero.exe.OLD" could be silently skipped by every caller's fail-closed check. Fold both sides before matching. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fbdec-f8dc-71f9-abdd-ea044a902b9a Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Amp-Thread-ID: https://ampcode.com/threads/T-019fbec7-a912-7462-8938-1b4a8d95f1f3 Co-authored-by: Pierre Bruno <pierrebruno@hotmail.ch>
Failure-path staging cleanup on Windows re-resolved the staging pathname after the exclusive handle was closed, so a principal who can write in the installation directory could substitute that entry in the gap and have the updater delete a file of their choosing. Removal is now requested through the handle (FileDispositionInfo) before it is released, and nothing is removed by name afterwards; the same change covers the staging-file verification failure path and the partial recovery marker. CleanupStaleBinary has had no production caller since bounded, identity-bound cleanup moved into promote, so the exported no-op and the tests that could only pass vacuously against it are removed. The POSIX link-regression tests now drive createStagingFileAt, the primitive createStagedBinary actually uses, instead of a path-taking helper kept alive only for them. Also documents the fail-closed Windows recovery states in docs/UPDATE.md, including the refusal a planted .old/.keep pair can cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the 2026-08-02 review round on Gitlawb#751. Keep the recovery restore bound to the object promote moved aside instead of its pathname: promote now opens the target with delete access and no delete sharing before renaming it aside, and restoreOriginalBinary renames that same handle back (retrying through the handle, never re-resolving the source name), so a directory writer cannot swap the aside entry during the promotion gap and have the substitute installed. Run the recovery-state checks as a standalone-install precondition, under the same lock promotion uses, so "already up to date" can no longer report success while a .keep/.recovery state is unresolved. The in-promotion check stays to close the race. Make the cleanup record a queue: entries are only retired once their object is actually gone, so a copy held open by a scanner is retried by a later update rather than leaking, while records for paths that vanished or that this updater could never have created are retired instead of accumulating. Also from review of the above: - delete the pathname restore chain that lost its production callers (restoreOriginalBinary's pathname form, openIdentityFile, recordRecoveryCleanup) and point their tests at the live handle path - name the relocation path in the error when the post-move verification fails, since oldPath is already vacated by then - scope the recovery docs to Windows and describe POSIX replace semantics Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cee1f07 to
db33a32
Compare
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approving. @PierrunoYT this is a well-built fix and it was sitting without a human review, which is on us rather than you.
I rebased it onto current main and pushed that to your branch, db33a320, because it had gone conflicting and could not merge. One docs conflict, where main had rewritten a sentence just above the recovery-state section you added; I kept main's newer wording and your whole section. Your authorship is preserved on every commit. Shout if you would rather redo it yourself.
What I checked rather than took from the description.
The staging name is 16 crypto/rand bytes hex-encoded, so the path is not guessable in advance. That alone is not the control though, and you did not treat it as one, which is the part I liked: the creation is exclusive and refuses links on both platforms.
On Unix, O_CREAT|O_EXCL|O_WRONLY|O_CLOEXEC|O_NOFOLLOW at 0o755. On Windows, CreateFile with CREATE_NEW, FILE_FLAG_OPEN_REPARSE_POINT and share mode 0.
Those two Windows flags cover the two different attacks, and it is worth naming which does what, because it is easy to think one flag is enough. FILE_FLAG_OPEN_REPARSE_POINT stops a junction or symlink being followed. A HARD LINK is neither, so that flag would not help there, but CREATE_NEW fails if the path exists at all, which is exactly what a pre-created hard link means. Between them the primitive is closed from both directions, and the unpredictable name means an attacker has to win a race they cannot see the start of.
I have been in this area on the Windows sandbox this week and FILE_FLAG_OPEN_REPARSE_POINT is the right control; a guard testing only os.ModeSymlink misses a junction entirely, which is a mistake this PR does not make.
Failure cleanup goes through defer staged.discard() rather than being left to the caller.
Scope and tests. 17 files, all in internal/update plus docs/UPDATE.md, with no drive-by changes elsewhere. Most of the volume is tests, including separate Windows and non-Windows paths and a staging seam so the random suffix is deterministic under test. That is the right shape for a security fix that behaves differently per platform.
State on this head. Build, vet and gofmt clean, cross-compiled for windows, linux and darwin. internal/update green. git diff --check clean. CI was green before the rebase and will re-run on db33a320; I will flag it here if anything comes back red rather than leave you to find it.
Over to @kevincodex1 to merge.
The identity check and the mutation used two different resolutions of the same name: runtimeDirIdentity opened a handle, read the volume and file ID, closed it, and then os.Remove or os.WriteFile resolved the pathname again. A rename followed by a replacement in that interval makes the comparison true about one object while the write or the delete lands on another. This is elevated compensation, so a redirected pathname gives the mutation reach the replacer does not have directly. Open once, verify the identity on that handle, and perform the mutation through it: the stamp relative to the directory handle, the created directory by FileDispositionInfo on its own handle. No ancestor is re-resolved and there is no interval to land in. A seam between the check and the mutation drives the replacement in tests; with the old pathname resolution they fail, naming the substitute. Two things fell out of doing it properly. The stamp restore now deletes and recreates through the ordinary writer rather than overwriting. The stamp carries a protected DACL that withholds write, so an in-place overwrite is denied under the token that wrote it, and only the writer puts that DACL back on the replacement. The reader ACE gains DELETE alongside read. Withholding write from the SANDBOX is the real boundary and the capability SID has no ACE here at all. Withholding it from the root owner is not one: they own the parent, so delete-then-create forges a stamp exactly as well as an overwrite. What read-only actually cost was rollback's ability to remove a stamp this run wrote. A directory removal that reports success is verified rather than assumed, for the reason recorded on the promote rename in #751.
The identity check and the mutation used two different resolutions of the same name: runtimeDirIdentity opened a handle, read the volume and file ID, closed it, and then os.Remove or os.WriteFile resolved the pathname again. A rename followed by a replacement in that interval makes the comparison true about one object while the write or the delete lands on another. This is elevated compensation, so a redirected pathname gives the mutation reach the replacer does not have directly. Open once, verify the identity on that handle, and perform the mutation through it: the stamp relative to the directory handle, the created directory by FileDispositionInfo on its own handle. No ancestor is re-resolved and there is no interval to land in. A seam between the check and the mutation drives the replacement in tests; with the old pathname resolution they fail, naming the substitute. Two things fell out of doing it properly. The stamp restore now deletes and recreates through the ordinary writer rather than overwriting. The stamp carries a protected DACL that withholds write, so an in-place overwrite is denied under the token that wrote it, and only the writer puts that DACL back on the replacement. The reader ACE gains DELETE alongside read. Withholding write from the SANDBOX is the real boundary and the capability SID has no ACE here at all. Withholding it from the root owner is not one: they own the parent, so delete-then-create forges a stamp exactly as well as an overwrite. What read-only actually cost was rollback's ability to remove a stamp this run wrote. A directory removal that reports success is verified rather than assumed, for the reason recorded on the promote rename in #751.
Summary
Fixes #742 — the Windows updater staged verified executable bytes at the predictable path
<target>.newand opened it with truncating, link-following semantics, letting a lower-privileged process pre-create that path as a hard link or reparse point to another file the (possibly elevated) updater can write.Root cause
installBinary(internal/update/apply.go) always staged at a fixed<target>.newname viacopyFile, which opened the destination withO_CREATE|O_WRONLY|O_TRUNC. In an installation directory writable by a lower-privileged principal, that principal could pre-create<target>.newas a hard link or supported reparse-point link to another file writable by the elevated updater. The subsequent staging write then truncated and overwrote that unintended file with the verified Zero executable bytes.The
.oldrename/removal sequence inreplace_windows.gowas not itself the vulnerable primitive (per the issue) and is unchanged.Changes
stagingFilePathnow derives the staging name fromcrypto/rand(128 bits), so it can no longer be predicted or pre-created before the update runs.createStagingFile(platform-specific) opens the staging path exclusively without following any pre-existing link:internal/update/stage_other.go(POSIX):O_CREATE|O_EXCL, which POSIX guarantees fails on a pre-existing path — including a dangling symlink — without resolving it.internal/update/stage_windows.go:CreateFilewithCREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINTso a pre-existing reparse point fails creation instead of being resolved through, plus a post-openGetFileInformationByHandlecheck (not a reparse point, not a directory, single hard link) as defense in depth.randomStagingSuffixis a swappable package var so the existing helper-refresh-failure test can pin a deterministic suffix instead of relying on the old guessable name.Tests
internal/update/stage_other_test.go/stage_windows_test.goreproduce the reported primitive directly: pre-create the staging path as a hard link (and, where privileges allow, a symlink) to a "victim" file, then assertcreateStagingFilerefuses it and the victim is untouched. Also covers the fresh-path success control and a concurrent-race case (exactly one winner, no silent truncation).TestApplyStandaloneUpdateWarnsWhenHelperRefreshFailsto pin the staging suffix via the new test hook instead of relying on the removed fixed.newname.Verification
go build ./...— pass (windows, plus cross-compiled linux/darwin forinternal/update)go vet ./...— pass (same platforms)go test ./internal/update/... -count=1— pass, including all new regression tests (hard link, symlink, concurrent race, fresh path) on Windowsgofmt -l— cleanSummary by CodeRabbit