Skip to content

Use os.CreateTemp for atomic file rewrites#102

Open
dolph wants to merge 1 commit into
mainfrom
claude/fix-issue-3-csprng-temp-files
Open

Use os.CreateTemp for atomic file rewrites#102
dolph wants to merge 1 commit into
mainfrom
claude/fix-issue-3-csprng-temp-files

Conversation

@dolph
Copy link
Copy Markdown
Owner

@dolph dolph commented Jun 2, 2026

Summary

Fixes a priority: critical security bug. File.Write previously used
a predictable temp-file name (RandomString(20), backed by the global
math/rand PRNG) and opened with os.WriteFile — i.e.
O_WRONLY|O_CREATE|O_TRUNC, no O_EXCL. Together these two
defects let a co-resident attacker:

  1. Predict or observe the temp-file name (non-CSPRNG randomness).
  2. Pre-create that path as a symlink to anywhere the running user can
    write. Because O_CREATE does not require the file to be new, the
    open followed the symlink and the new content (or the truncate)
    hit the attacker-chosen location.

The risk is highest when find-replace runs as a privileged user
(deployment scripts, CI runners, root-owned trees).

os.CreateTemp(dir, ".find-replace.*") closes both halves of the bug
in one call: it opens with O_RDWR|O_CREATE|O_EXCL|0600 and chooses
the random suffix from crypto/rand. An attacker cannot predict the
name; even on a (statistically impossible) collision, O_EXCL would
refuse to clobber the pre-created path.

What changed

  • File.Write now uses os.CreateTemp(f.Dir(), ".find-replace.*").
  • The original file's permission bits are preserved by an explicit
    tmp.Chmod(mode.Perm()) before the rename (without it, every
    rewritten file would silently drop to 0600). Setuid/setgid/sticky
    preservation is intentionally out of scope — see Setuid/setgid/sticky bits are silently stripped from rewritten files #18.
  • The deferred os.Remove(tempName) cleanup-on-failure path is
    preserved; only the temp-file primitive changes.
  • strings.go::RandomString (and strings_test.go) are deleted —
    the rewrite path was the only production user, and the lone
    remaining caller (BenchmarkNova) now uses a fixed find/replace
    pair (which is more reproducible than two random characters anyway).
    This removes math/rand from the codebase entirely.

Follow-on

Issue #21 ("skip orphan .find-replace-* temp files on subsequent
walks") becomes straightforward to implement on top of this once the
.find-replace.* prefix convention is in place — the walker can
filter on filepath.Glob(... ".find-replace.*") without needing any
agreement-by-convention with the temp-file producer.

Tests (TDD)

Four new tests in file_handling_test.go, all hermetic via
t.TempDir():

  1. TestWrite_TempFileUsesORDWREXCL — pre-creates a sentinel file
    sharing the temp prefix; the rewrite must leave it untouched
    (documents the O_EXCL invariant).
  2. 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).
  3. TestWrite_PreservesOriginalPermissionBits (table-driven: 0644,
    0600, 0755) — without the explicit Chmod after CreateTemp,
    the rewrite silently drops the file to 0600. Verified to fail
    on a no-Chmod stub before landing the production code.
  4. 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 TestWriteCleansUpTempFileOnRenameFailure continues
to exercise the deferred-cleanup path against the new
os.CreateTemp-based implementation.

Test plan

  • gofmt -l . — clean
  • go vet ./... — clean
  • go build ./... — clean
  • go test -race ./... — all pass (including 4 new TestWrite_*
    tests and the 3 permission-bit sub-tests)
  • ./build.sh — succeeds, 77.3% coverage
  • End-to-end smoke test: rewrote a real tree with the rebuilt
    binary, verified content rewritten, file/dir renamed, original
    0644 permission bits preserved
  • strace -e openat against the built binary confirmed
    openat(... ".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

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).
@dolph dolph added the release:patch label Jun 2, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Predictable temp-file names enable a symlink/race attack on file rewrites

2 participants