security(update): stage binary replacement at an unpredictable, exclusive path#751
Conversation
…sive path Fixes Gitlawb#742. installBinary staged downloaded release bytes at the fixed <target>.new path and opened it with O_CREATE|O_WRONLY|O_TRUNC. In an installation directory writable by a lower-privileged process, that process could pre-create <target>.new as a hard link or reparse point to another file the (possibly elevated) updater can write; the truncating open then overwrote that unintended file with verified Zero executable bytes. stagingFilePath now generates a cryptographically random suffix so the path can't be targeted in advance, and platform-specific createStagingFile opens it exclusively without following a pre-existing link: O_CREATE|O_EXCL on POSIX (which POSIX guarantees fails on a pre-existing path, symlink or not, without resolving it), and CreateFile with CREATE_NEW|FILE_FLAG_OPEN_REPARSE_POINT plus a post-open GetFileInformationByHandle check on Windows (not a reparse point, not a directory, single hard link). replace_windows.go's rename-swap sequence is unchanged: the dangerous operation was always the truncating open of the staged path, not the .old rename/removal that follows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
| const attempts = 16 | ||
| var wg sync.WaitGroup | ||
| successes := make([]bool, attempts) | ||
| for i := range attempts { |
| const attempts = 16 | ||
| var wg sync.WaitGroup | ||
| successes := make([]bool, attempts) | ||
| for i := range attempts { |
WalkthroughThe updater now generates unpredictable staging filenames and creates staging files exclusively. POSIX and Windows implementations reject pre-existing links or invalid handles, while tests cover fresh creation, link protection, concurrent races, and helper refresh failures. ChangesSecure updater staging
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant installBinary
participant stagingFilePath
participant copyFile
participant createStagingFile
installBinary->>stagingFilePath: Generate random staging path
installBinary->>copyFile: Copy binary to staged path
copyFile->>createStagingFile: Create path exclusively
createStagingFile-->>copyFile: Return validated file handle
copyFile-->>installBinary: Complete staged binary
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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
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