diff --git a/internal/tools/filesystem.go b/internal/tools/filesystem.go index cff20b9c..92b9be33 100644 --- a/internal/tools/filesystem.go +++ b/internal/tools/filesystem.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strings" - "syscall" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/sandbox" @@ -374,49 +373,5 @@ func resolveThroughExistingAncestors(target string) (string, error) { return filepath.Clean(target), nil } -// hasMutableSymlinkParent checks if any component of the resolved path is a symlink -// whose parent directory is writable by the current process. A writable parent means -// the symlink could be replaced between path resolution and actual file operation -// (TOCTOU symlink rebind attack). -func hasMutableSymlinkParent(path string) bool { - clean := filepath.Clean(path) - components := strings.Split(clean, string(filepath.Separator)) - current := string(filepath.Separator) - for _, comp := range components { - if comp == "" { - continue - } - current = filepath.Join(current, comp) - info, err := os.Lstat(current) - if err != nil { - break // non-existent — stop checking - } - if info.Mode()&os.ModeSymlink != 0 { - // Symlink found — check if its parent dir is writable - parentDir := filepath.Dir(current) - if syscall.Access(parentDir, 0x2 /* W_OK */) == nil { - return true - } - } - } - return false -} - -// checkHardlink rejects regular files with nlink > 1 (hardlink attack prevention). -// Directories naturally have nlink > 1 and are exempt. -func checkHardlink(path string) error { - info, err := os.Lstat(path) - if err != nil { - return nil // non-existent files are OK — will fail at read/write - } - if info.IsDir() { - return nil - } - if stat, ok := info.Sys().(*syscall.Stat_t); ok { - if stat.Nlink > 1 { - slog.Warn("security.hardlink_rejected", "path", path, "nlink", stat.Nlink) - return fmt.Errorf("access denied: hardlinked file not allowed") - } - } - return nil -} +// hasMutableSymlinkParent and checkHardlink are implemented in +// filesystem_unix.go (Linux/macOS) and filesystem_windows.go (Windows). diff --git a/internal/tools/filesystem_unix.go b/internal/tools/filesystem_unix.go new file mode 100644 index 00000000..aa240bcc --- /dev/null +++ b/internal/tools/filesystem_unix.go @@ -0,0 +1,59 @@ +//go:build !windows + +package tools + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "syscall" +) + +// hasMutableSymlinkParent checks if any component of the resolved path is a symlink +// whose parent directory is writable by the current process. A writable parent means +// the symlink could be replaced between path resolution and actual file operation +// (TOCTOU symlink rebind attack). +func hasMutableSymlinkParent(path string) bool { + clean := filepath.Clean(path) + components := strings.Split(clean, string(filepath.Separator)) + current := string(filepath.Separator) + for _, comp := range components { + if comp == "" { + continue + } + current = filepath.Join(current, comp) + info, err := os.Lstat(current) + if err != nil { + break // non-existent — stop checking + } + if info.Mode()&os.ModeSymlink != 0 { + // Symlink found — check if its parent dir is writable + parentDir := filepath.Dir(current) + if syscall.Access(parentDir, 0x2 /* W_OK */) == nil { + return true + } + } + } + return false +} + +// checkHardlink rejects regular files with nlink > 1 (hardlink attack prevention). +// Directories naturally have nlink > 1 and are exempt. +func checkHardlink(path string) error { + info, err := os.Lstat(path) + if err != nil { + return nil // non-existent files are OK — will fail at read/write + } + if info.IsDir() { + return nil + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + if stat.Nlink > 1 { + slog.Warn("security.hardlink_rejected", "path", path, "nlink", stat.Nlink) + return fmt.Errorf("access denied: hardlinked file not allowed") + } + } + return nil +} diff --git a/internal/tools/filesystem_windows.go b/internal/tools/filesystem_windows.go new file mode 100644 index 00000000..26fb8548 --- /dev/null +++ b/internal/tools/filesystem_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package tools + +import ( + "os" + "path/filepath" + "strings" +) + +// hasMutableSymlinkParent checks for mutable symlink parents. +// On Windows, symlink rebind attacks are less common; we do a best-effort check +// using file attribute inspection instead of syscall.Access. +func hasMutableSymlinkParent(path string) bool { + clean := filepath.Clean(path) + components := strings.Split(clean, string(filepath.Separator)) + current := "" + for _, comp := range components { + if comp == "" { + continue + } + if current == "" { + current = comp + } else { + current = filepath.Join(current, comp) + } + info, err := os.Lstat(current) + if err != nil { + break // non-existent — stop checking + } + if info.Mode()&os.ModeSymlink != 0 { + parentDir := filepath.Dir(current) + // Check writability by attempting to open for writing + if isDirWritable(parentDir) { + return true + } + } + } + return false +} + +// isDirWritable checks if a directory is writable by attempting to create a temp file. +func isDirWritable(dir string) bool { + f, err := os.CreateTemp(dir, ".wrchk") + if err != nil { + return false + } + f.Close() + os.Remove(f.Name()) + return true +} + +// checkHardlink rejects regular files with nlink > 1 (hardlink attack prevention). +// On Windows, os.FileInfo.Sys() does not expose nlink, so we skip the check. +func checkHardlink(path string) error { + return nil +}