Use os.CreateTemp for atomic file rewrites#102
Open
dolph wants to merge 1 commit into
Open
Conversation
Replaces the hand-rolled temp file pattern in File.Write with os.CreateTemp, which opens with O_RDWR|O_CREATE|O_EXCL|0600 and a CSPRNG-quality random suffix. The combination defends against the symlink/race attack documented in issue #3: 1. The previous temp-file name came from RandomString(20) backed by the global math/rand PRNG, which a co-resident attacker could observe or predict. 2. os.WriteFile opens with O_WRONLY|O_CREATE|O_TRUNC (no O_EXCL), so if an attacker pre-created the temp path as a symlink to a path the running user could write, the open followed the symlink and the new content (or the truncate) hit the attacker-chosen target. os.CreateTemp closes both halves of the bug in one call. Temp files now share the `.find-replace.*` prefix (leading dot keeps them hidden from `ls`) which gives issue #21 a stable glob to filter on. Permission preservation is maintained explicitly: os.CreateTemp creates the file 0600, so the temp file is Chmod'd back to the original mode.Perm() before the rename. Setuid/setgid/sticky bits are intentionally NOT preserved here — see issue #18. The defer os.Remove(tempName) cleanup path is preserved; only the temp-file primitive changes. The unused RandomString helper (and its dedicated tests) are removed along with strings.go and strings_test.go; the only remaining caller was BenchmarkNova, which now uses a fixed find/replace pair (more reproducible than two random characters).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a
priority: criticalsecurity bug.File.Writepreviously useda predictable temp-file name (
RandomString(20), backed by the globalmath/randPRNG) and opened withos.WriteFile— i.e.O_WRONLY|O_CREATE|O_TRUNC, noO_EXCL. Together these twodefects let a co-resident attacker:
write. Because
O_CREATEdoes not require the file to be new, theopen followed the symlink and the new content (or the truncate)
hit the attacker-chosen location.
The risk is highest when
find-replaceruns as a privileged user(deployment scripts, CI runners, root-owned trees).
os.CreateTemp(dir, ".find-replace.*")closes both halves of the bugin one call: it opens with
O_RDWR|O_CREATE|O_EXCL|0600and choosesthe random suffix from
crypto/rand. An attacker cannot predict thename; even on a (statistically impossible) collision,
O_EXCLwouldrefuse to clobber the pre-created path.
What changed
File.Writenow usesos.CreateTemp(f.Dir(), ".find-replace.*").tmp.Chmod(mode.Perm())before the rename (without it, everyrewritten file would silently drop to
0600). Setuid/setgid/stickypreservation is intentionally out of scope — see Setuid/setgid/sticky bits are silently stripped from rewritten files #18.
os.Remove(tempName)cleanup-on-failure path ispreserved; only the temp-file primitive changes.
strings.go::RandomString(andstrings_test.go) are deleted —the rewrite path was the only production user, and the lone
remaining caller (
BenchmarkNova) now uses a fixed find/replacepair (which is more reproducible than two random characters anyway).
This removes
math/randfrom the codebase entirely.Follow-on
Issue #21 ("skip orphan
.find-replace-*temp files on subsequentwalks") becomes straightforward to implement on top of this once the
.find-replace.*prefix convention is in place — the walker canfilter on
filepath.Glob(... ".find-replace.*")without needing anyagreement-by-convention with the temp-file producer.
Tests (TDD)
Four new tests in
file_handling_test.go, all hermetic viat.TempDir():TestWrite_TempFileUsesORDWREXCL— pre-creates a sentinel filesharing the temp prefix; the rewrite must leave it untouched
(documents the
O_EXCLinvariant).TestWrite_TempFilePrefixVisible— after a successful rewrite,no
.find-replace.*entry lingers in the target directory(enforces the deferred-cleanup contract that Stale .find-replace-* temp files from crashed runs are not skipped on re-run #21 will rely on).
TestWrite_PreservesOriginalPermissionBits(table-driven: 0644,0600, 0755) — without the explicit
ChmodafterCreateTemp,the rewrite silently drops the file to 0600. Verified to fail
on a no-Chmod stub before landing the production code.
TestWrite_DoesNotFollowAttackerPreparedSymlinkAtTempPath—pre-creates a decoy symlink under the temp prefix pointing at a
sensitive file; the rewrite must (a) succeed, (b) leave the decoy
as a symlink, (c) leave the decoy's target unchanged.
The pre-existing
TestWriteCleansUpTempFileOnRenameFailurecontinuesto exercise the deferred-cleanup path against the new
os.CreateTemp-based implementation.Test plan
gofmt -l .— cleango vet ./...— cleango build ./...— cleango test -race ./...— all pass (including 4 new TestWrite_*tests and the 3 permission-bit sub-tests)
./build.sh— succeeds, 77.3% coveragebinary, verified content rewritten, file/dir renamed, original
0644permission bits preservedstrace -e openatagainst the built binary confirmedopenat(... ".find-replace.NNNN", O_RDWR|O_CREAT|O_EXCL|O_CLOEXEC, 0600)— the security fix is in effect at the syscall level
Closes #3
https://claude.ai/code/session_01Tep5t8h97Q9KKbpLMbUEJr
Generated by Claude Code