Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 2 additions & 47 deletions internal/tools/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strings"
"syscall"

"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
Expand Down Expand Up @@ -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).
59 changes: 59 additions & 0 deletions internal/tools/filesystem_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 57 additions & 0 deletions internal/tools/filesystem_windows.go
Original file line number Diff line number Diff line change
@@ -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
}