Skip to content

Commit c8b403a

Browse files
dolphclaude
andauthored
Skip symlinks during traversal (#68)
* Skip symbolic links during traversal `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 * Probe symlink capability instead of skipping by GOOS Review feedback on #68: rather than blanket-skipping the symlink tests when runtime.GOOS == "windows", explicitly probe for the ability to create a symlink and skip on actual failure. That way the tests run wherever the capability is available (e.g. a Windows runner with SeCreateSymbolicLinkPrivilege, a sandboxed container that happens to allow symlink creation) and are correctly skipped on a host that lacks it (including a non-Windows host with a restrictive seccomp/AppArmor profile or filesystem). The probe is a t.Helper named requireSymlinks that creates a real symlink in a fresh t.TempDir; if Symlink returns an error, the test t.Skips with that error embedded so it's clear why. The Windows guards in TestWalkDir_PermissionDeniedSubdirContinues and TestWriteCleansUpTempFileOnRenameFailure are intentionally kept: those skips are about non-symlink semantic differences (permission bits and rename-onto-directory), not symlink capability. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5ab94c4 commit c8b403a

2 files changed

Lines changed: 287 additions & 0 deletions

File tree

find_replace.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"io"
7+
"io/fs"
78
"log"
89
"os"
910
"path/filepath"
@@ -125,6 +126,16 @@ func (fr *findReplace) WalkDir(f *File) {
125126

126127
for _, file := range files {
127128
childPath := filepath.Join(f.Path, file.Name())
129+
// Skip symbolic links by default. os.Stat (used downstream by
130+
// File.Info) silently follows symlinks, which would let a link
131+
// inside the working tree escape the search root and rewrite
132+
// arbitrary files elsewhere on the filesystem (see issue #2).
133+
// fs.DirEntry.Type() reports the symlink bit directly from the
134+
// readdir call, so we don't need an extra lstat here.
135+
if file.Type()&fs.ModeSymlink != 0 {
136+
log.Printf("Skipping symlink: %v", childPath)
137+
continue
138+
}
128139
childFile, err := NewFile(childPath)
129140
if err != nil {
130141
log.Print(err)

find_replace_test.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,25 @@ func readOrFatal(tb testing.TB, f *File) string {
7474
return s
7575
}
7676

77+
// requireSymlinks skips the calling test if the test process cannot create
78+
// symbolic links. On Windows this typically requires
79+
// SeCreateSymbolicLinkPrivilege; on sandboxed Linux filesystems and certain
80+
// container configurations the syscall can also fail with EPERM/ENOSYS.
81+
// Probing for the actual capability avoids skipping on a host where symlinks
82+
// happen to work, and avoids running on a host where they happen not to.
83+
func requireSymlinks(t *testing.T) {
84+
t.Helper()
85+
probe := t.TempDir()
86+
target := filepath.Join(probe, "target")
87+
if err := os.WriteFile(target, nil, 0600); err != nil {
88+
t.Fatalf("requireSymlinks setup: WriteFile(%q): %v", target, err)
89+
}
90+
link := filepath.Join(probe, "link")
91+
if err := os.Symlink(target, link); err != nil {
92+
t.Skipf("symlinks not supported in this environment: %v", err)
93+
}
94+
}
95+
7796
func expectedPathAfterRename(f *File, fr *findReplace) string {
7897
return filepath.Join(f.Dir(), strings.ReplaceAll(f.Base(), fr.find, fr.replace))
7998
}
@@ -684,3 +703,260 @@ func BenchmarkNova(b *testing.B) {
684703
fr.WalkDir(d)
685704
}
686705
}
706+
707+
// TestWalkDir_SymlinkToFileOutsideCWDNotFollowed ensures the walker does not
708+
// rewrite a file reached via a symlink whose target lives outside the search
709+
// root. Following the symlink would violate find-replace's documented contract
710+
// ("Searches are performed recursively from the current working directory")
711+
// and is a known privilege-escalation primitive (see issue #2).
712+
func TestWalkDir_SymlinkToFileOutsideCWDNotFollowed(t *testing.T) {
713+
requireSymlinks(t)
714+
715+
// outside holds the file that must NOT be touched by the walk.
716+
outside := t.TempDir()
717+
outsideFile := filepath.Join(outside, "outside.txt")
718+
const outsideContent = "alpha-outside"
719+
if err := os.WriteFile(outsideFile, []byte(outsideContent), 0600); err != nil {
720+
t.Fatalf("WriteFile(%q): %v", outsideFile, err)
721+
}
722+
723+
// root is the search root. It contains a real file and a symlink that
724+
// escapes to outsideFile.
725+
root := t.TempDir()
726+
insideFile := filepath.Join(root, "inside.txt")
727+
if err := os.WriteFile(insideFile, []byte("alpha-inside"), 0600); err != nil {
728+
t.Fatalf("WriteFile(%q): %v", insideFile, err)
729+
}
730+
escape := filepath.Join(root, "escape.txt")
731+
if err := os.Symlink(outsideFile, escape); err != nil {
732+
t.Fatalf("Symlink(%q, %q): %v", outsideFile, escape, err)
733+
}
734+
735+
rootFile := newFileOrFatal(t, root)
736+
fr := findReplace{find: "alpha", replace: "beta"}
737+
fr.WalkDir(rootFile)
738+
739+
if err := fr.errs.err(); err != nil {
740+
t.Errorf("WalkDir reported unexpected error(s): %v", err)
741+
}
742+
743+
// The real file inside the root must have been rewritten.
744+
got, err := os.ReadFile(insideFile)
745+
if err != nil {
746+
t.Fatalf("ReadFile(%q): %v", insideFile, err)
747+
}
748+
if string(got) != "beta-inside" {
749+
t.Errorf("inside.txt = %q; want %q", string(got), "beta-inside")
750+
}
751+
752+
// The symlink target outside the root must NOT have been rewritten.
753+
got, err = os.ReadFile(outsideFile)
754+
if err != nil {
755+
t.Fatalf("ReadFile(%q): %v", outsideFile, err)
756+
}
757+
if string(got) != outsideContent {
758+
t.Errorf("outside.txt = %q; want %q (symlink was followed)", string(got), outsideContent)
759+
}
760+
761+
// The symlink itself should still be present as a symlink (the walker
762+
// should have skipped it, not replaced it with a regular file or renamed
763+
// it).
764+
info, err := os.Lstat(escape)
765+
if err != nil {
766+
t.Fatalf("Lstat(%q) after walk: %v (symlink was removed/renamed)", escape, err)
767+
}
768+
if info.Mode()&fs.ModeSymlink == 0 {
769+
t.Errorf("Lstat(%q).Mode() = %v; want a symlink (was replaced with a regular file)", escape, info.Mode())
770+
}
771+
}
772+
773+
// TestWalkDir_SymlinkToDirOutsideCWDNotTraversed ensures the walker does not
774+
// recurse through a symlink whose target is a directory outside the search
775+
// root. This is the directory variant of the issue #2 reproducer.
776+
func TestWalkDir_SymlinkToDirOutsideCWDNotTraversed(t *testing.T) {
777+
requireSymlinks(t)
778+
779+
// outside is a directory tree we must not touch.
780+
outside := t.TempDir()
781+
outsideFile := filepath.Join(outside, "victim.txt")
782+
const outsideContent = "alpha-outside"
783+
if err := os.WriteFile(outsideFile, []byte(outsideContent), 0600); err != nil {
784+
t.Fatalf("WriteFile(%q): %v", outsideFile, err)
785+
}
786+
787+
root := t.TempDir()
788+
escape := filepath.Join(root, "escape")
789+
if err := os.Symlink(outside, escape); err != nil {
790+
t.Fatalf("Symlink(%q, %q): %v", outside, escape, err)
791+
}
792+
793+
rootFile := newFileOrFatal(t, root)
794+
fr := findReplace{find: "alpha", replace: "beta"}
795+
fr.WalkDir(rootFile)
796+
797+
if err := fr.errs.err(); err != nil {
798+
t.Errorf("WalkDir reported unexpected error(s): %v", err)
799+
}
800+
801+
// The outside file must NOT have been rewritten.
802+
got, err := os.ReadFile(outsideFile)
803+
if err != nil {
804+
t.Fatalf("ReadFile(%q): %v", outsideFile, err)
805+
}
806+
if string(got) != outsideContent {
807+
t.Errorf("victim.txt = %q; want %q (symlinked directory was traversed)", string(got), outsideContent)
808+
}
809+
810+
// The outside file must NOT have been renamed.
811+
renamed := filepath.Join(outside, "beta-victim.txt")
812+
if _, err := os.Lstat(renamed); err == nil {
813+
t.Errorf("Lstat(%q) succeeded; file inside symlinked dir was renamed", renamed)
814+
}
815+
816+
// The symlink itself should still be present.
817+
if _, err := os.Lstat(escape); err != nil {
818+
t.Errorf("Lstat(%q) after walk: %v (symlink was removed/renamed)", escape, err)
819+
}
820+
}
821+
822+
// TestWalkDir_SymlinkToFileInsideCWDNotFollowed verifies that even when a
823+
// symlink's target is inside the search root, the symlink itself is still
824+
// skipped. The target is still picked up via the normal walker pass over its
825+
// real path, so it is rewritten exactly once — never via the symlink.
826+
func TestWalkDir_SymlinkToFileInsideCWDNotFollowed(t *testing.T) {
827+
requireSymlinks(t)
828+
829+
root := t.TempDir()
830+
real := filepath.Join(root, "real.txt")
831+
if err := os.WriteFile(real, []byte("alpha"), 0600); err != nil {
832+
t.Fatalf("WriteFile(%q): %v", real, err)
833+
}
834+
link := filepath.Join(root, "link.txt")
835+
if err := os.Symlink(real, link); err != nil {
836+
t.Fatalf("Symlink(%q, %q): %v", real, link, err)
837+
}
838+
839+
rootFile := newFileOrFatal(t, root)
840+
fr := findReplace{find: "alpha", replace: "beta"}
841+
fr.WalkDir(rootFile)
842+
843+
if err := fr.errs.err(); err != nil {
844+
t.Errorf("WalkDir reported unexpected error(s): %v", err)
845+
}
846+
847+
// The real file should be rewritten via the normal pass — exactly once
848+
// (not twice — that is, the symlink was not also followed and rewritten).
849+
got, err := os.ReadFile(real)
850+
if err != nil {
851+
t.Fatalf("ReadFile(%q): %v", real, err)
852+
}
853+
if string(got) != "beta" {
854+
t.Errorf("real.txt = %q; want %q", string(got), "beta")
855+
}
856+
857+
// The symlink should still be a symlink pointing to the (now-rewritten)
858+
// real file. The walker must not have renamed or removed it.
859+
info, err := os.Lstat(link)
860+
if err != nil {
861+
t.Fatalf("Lstat(%q) after walk: %v (symlink was removed/renamed)", link, err)
862+
}
863+
if info.Mode()&fs.ModeSymlink == 0 {
864+
t.Errorf("Lstat(%q).Mode() = %v; want a symlink (was replaced with a regular file)", link, info.Mode())
865+
}
866+
}
867+
868+
// TestWalkDir_BrokenSymlinkSkippedWithoutError ensures a dangling symlink is
869+
// silently skipped, not reported as an error and not "rewritten" or "renamed".
870+
func TestWalkDir_BrokenSymlinkSkippedWithoutError(t *testing.T) {
871+
requireSymlinks(t)
872+
873+
root := t.TempDir()
874+
broken := filepath.Join(root, "alpha-broken")
875+
target := filepath.Join(root, "does-not-exist")
876+
if err := os.Symlink(target, broken); err != nil {
877+
t.Fatalf("Symlink(%q, %q): %v", target, broken, err)
878+
}
879+
880+
rootFile := newFileOrFatal(t, root)
881+
fr := findReplace{find: "alpha", replace: "beta"}
882+
fr.WalkDir(rootFile)
883+
884+
if err := fr.errs.err(); err != nil {
885+
t.Errorf("WalkDir reported unexpected error(s) for a broken symlink: %v", err)
886+
}
887+
888+
// The symlink must still be present under its original name (it was not
889+
// renamed alpha-broken -> beta-broken, nor was it followed).
890+
info, err := os.Lstat(broken)
891+
if err != nil {
892+
t.Fatalf("Lstat(%q) after walk: %v (broken symlink was removed/renamed)", broken, err)
893+
}
894+
if info.Mode()&fs.ModeSymlink == 0 {
895+
t.Errorf("Lstat(%q).Mode() = %v; want a symlink (was replaced with a regular file)", broken, info.Mode())
896+
}
897+
898+
// The renamed name should not have been created either.
899+
renamed := filepath.Join(root, "beta-broken")
900+
if _, err := os.Lstat(renamed); err == nil {
901+
t.Errorf("Lstat(%q) succeeded; broken symlink was renamed", renamed)
902+
}
903+
}
904+
905+
// TestWalkDir_RegularFileStillProcessed is a sanity check that the
906+
// symlink-skipping fix does not break the common case for regular files.
907+
func TestWalkDir_RegularFileStillProcessed(t *testing.T) {
908+
root := t.TempDir()
909+
src := filepath.Join(root, "alpha.txt")
910+
if err := os.WriteFile(src, []byte("alpha"), 0600); err != nil {
911+
t.Fatalf("WriteFile(%q): %v", src, err)
912+
}
913+
914+
rootFile := newFileOrFatal(t, root)
915+
fr := findReplace{find: "alpha", replace: "beta"}
916+
fr.WalkDir(rootFile)
917+
918+
if err := fr.errs.err(); err != nil {
919+
t.Errorf("WalkDir reported unexpected error(s): %v", err)
920+
}
921+
922+
renamed := filepath.Join(root, "beta.txt")
923+
got, err := os.ReadFile(renamed)
924+
if err != nil {
925+
t.Fatalf("ReadFile(%q): %v", renamed, err)
926+
}
927+
if string(got) != "beta" {
928+
t.Errorf("beta.txt = %q; want %q", string(got), "beta")
929+
}
930+
}
931+
932+
// TestWalkDir_RegularDirStillTraversed is the directory variant of the sanity
933+
// check.
934+
func TestWalkDir_RegularDirStillTraversed(t *testing.T) {
935+
root := t.TempDir()
936+
sub := filepath.Join(root, "alpha-dir")
937+
if err := os.Mkdir(sub, 0700); err != nil {
938+
t.Fatalf("Mkdir(%q): %v", sub, err)
939+
}
940+
child := filepath.Join(sub, "alpha.txt")
941+
if err := os.WriteFile(child, []byte("alpha"), 0600); err != nil {
942+
t.Fatalf("WriteFile(%q): %v", child, err)
943+
}
944+
945+
rootFile := newFileOrFatal(t, root)
946+
fr := findReplace{find: "alpha", replace: "beta"}
947+
fr.WalkDir(rootFile)
948+
949+
if err := fr.errs.err(); err != nil {
950+
t.Errorf("WalkDir reported unexpected error(s): %v", err)
951+
}
952+
953+
renamedDir := filepath.Join(root, "beta-dir")
954+
renamedChild := filepath.Join(renamedDir, "beta.txt")
955+
got, err := os.ReadFile(renamedChild)
956+
if err != nil {
957+
t.Fatalf("ReadFile(%q): %v", renamedChild, err)
958+
}
959+
if string(got) != "beta" {
960+
t.Errorf("beta-dir/beta.txt = %q; want %q", string(got), "beta")
961+
}
962+
}

0 commit comments

Comments
 (0)