Skip to content

Skip symlinks during traversal#68

Open
dolph wants to merge 1 commit into
mainfrom
claude/fix-issue-2-skip-symlinks
Open

Skip symlinks during traversal#68
dolph wants to merge 1 commit into
mainfrom
claude/fix-issue-2-skip-symlinks

Conversation

@dolph
Copy link
Copy Markdown
Owner

@dolph dolph commented May 18, 2026

Summary

Fixes a priority: critical security bug: find-replace was silently
following symbolic links inside the working tree because it dispatched
on os.Stat (which follows symlinks) rather than the symlink-aware
fs.DirEntry already returned by os.ReadDir. Any symlink to a path
outside the current working directory — tmp -> /etc,
home -> /home/victim, or a malicious dependency dropping
node_modules/something/etc -> /etc — was transparently resolved and
walked, letting the tool rewrite file contents and rename files
inside the symlink target. This violates the documented contract
("Searches are performed recursively from the current working
directory") and is a real privilege-escalation primitive when the tool
is run with elevated permissions (CI, root).

Threat model & fix

In WalkDir, the per-entry fs.DirEntry.Type() already carries the
symlink bit from the underlying getdents64/readdir syscall — no
extra lstat is needed. Before constructing a *File and dispatching
into HandleFile, we now mask entry.Type()&fs.ModeSymlink and skip
the entry with a one-line log.Printf("Skipping symlink: …") so the
operator can see what was skipped. The downstream File.Info()
os.Stat is left in place — only the dispatch decision is changed.

Scope

  • No --follow-symlinks opt-in flag in this PR (additive CLI surface,
    would require release:minor, and is out of scope for a security
    bug fix). File-as-a-follow-up if desired.
  • Issue Redundant os.Stat call per directory entry duplicates information already in DirEntry #13 (drop the per-entry os.Stat in HandleFile's dispatch)
    is related but deliberately deferred: this fix touches only the
    symlink check in WalkDir and leaves HandleFile's
    directory-vs-file dispatch alone, per the single-purpose-PR
    guidance in AGENTS.md. The relevant refactor will be a follow-up.

Tests (TDD)

Six new tests in find_replace_test.go, all t.TempDir()-hermetic and
Windows-skipped because creating symlinks on Windows typically requires
elevation:

  1. TestWalkDir_SymlinkToFileOutsideCWDNotFollowed — symlink target
    is a file outside the search root; the target must remain untouched
    and the symlink itself must remain a symlink.
  2. TestWalkDir_SymlinkToDirOutsideCWDNotTraversed — symlink target
    is a directory outside the search root; nothing inside it may be
    rewritten or renamed.
  3. TestWalkDir_SymlinkToFileInsideCWDNotFollowed — even when the
    target is inside the search root, the symlink is skipped, and the
    target is rewritten exactly once via its real path.
  4. TestWalkDir_BrokenSymlinkSkippedWithoutError — a dangling symlink
    is silently skipped, not reported as an error.
  5. TestWalkDir_RegularFileStillProcessed — sanity check for
    non-symlink files.
  6. TestWalkDir_RegularDirStillTraversed — sanity check for
    non-symlink directories.

Each was confirmed to fail for the right reason (assertion failure
rather than compile error) against the unpatched main before the
production change was applied.

Closes #2

Test plan

  • gofmt -l . — clean
  • go vet ./... — clean
  • go build ./... — clean
  • go test -race ./... — all pass, including 6 new symlink tests
  • ./build.sh — succeeds
  • Verified the new tests fail on main (assertion failures) before
    the fix was applied
  • Confirmed no new golangci-lint (errcheck) issues are
    introduced; the 7 pre-existing warnings on main are unchanged

https://claude.ai/code/session_01Tep5t8h97Q9KKbpLMbUEJr


Generated by Claude Code

`find-replace` uses `File.Info()` (a thin wrapper around `os.Stat`) to
decide whether a directory entry is a directory. `os.Stat` follows
symbolic links, so any symlink inside the working tree was transparently
resolved and walked — letting `find-replace` rewrite file contents and
rename files inside the symlink's target, anywhere on the filesystem.

This violates the documented contract ("Searches are performed
recursively from the current working directory") and is a real
privilege-escalation primitive when the tool is run with elevated
permissions (CI, root). See issue #2 for the reproducer.

Fix: in `WalkDir`, check the `fs.ModeSymlink` bit on the
`fs.DirEntry` returned by `os.ReadDir` and skip the entry before it
reaches `HandleFile`. The DirEntry already carries the symlink-bit from
the underlying `getdents64`/`readdir` call, so no extra `lstat` is
needed. The skip is logged as "Skipping symlink: <path>" so the
behavior is visible to operators.

No CLI surface change: there is intentionally no `--follow-symlinks`
opt-in flag in this commit. That would be additive and is filed as a
follow-up. Issue #13 (drop the per-entry `os.Stat` in `HandleFile`) is
related but requires a separate refactor of `HandleFile`'s dispatch and
is left for a follow-up.

Tests cover: outside-the-root file targets, outside-the-root directory
targets, inside-the-root targets (the symlink is still skipped, the
real path is still rewritten exactly once), broken symlinks (silently
skipped, no error recorded), and sanity checks for regular files and
directories.

Fixes #2
@dolph dolph added the release:patch label May 18, 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.

Symlink traversal can rewrite/rename arbitrary files outside the working directory

2 participants