Files
vikingowl 9853a522e6 refactor(security): consolidate TOCTOU-safe path canonicalization
3c87527 added engine/paths.go:resolveCanonical, duplicating the
ancestor-walk + EvalSymlinks algorithm that already lived in
fs/guard.go:ResolveWrite. Two implementations of the same TOCTOU defense
is exactly the wrong shape for security code — a bug fix in one would
silently miss the other.

Extracts the shared algorithm to security.CanonicalizePath. Both call
sites become thin wrappers that pre-anchor relative paths against the
appropriate root (cwd for engine, workspace root for guard). The
"hit-root" defensive branch in engine's version (commented "highly
unlikely") is tightened to match guard's error behavior.

Adds focused unit tests for the helper covering existing path,
non-existent leaf, non-existent mid-component, symlinked ancestor, and
relative-path rejection.
2026-05-20 01:50:38 +02:00

51 lines
1.6 KiB
Go

package security
import (
"fmt"
"os"
"path/filepath"
)
// CanonicalizePath resolves absPath to its absolute, symlink-evaluated form,
// tolerating non-existent leaf and intermediate components.
//
// The algorithm walks back from absPath toward "/" until it finds a path that
// exists (via Lstat), runs filepath.EvalSymlinks on that ancestor, then
// rejoins the non-existent tail. This defends against the TOCTOU sandbox
// escape "leaf does not exist yet, so EvalSymlinks errors → caller skips the
// symlink check → write proceeds outside the workspace through a symlinked
// parent."
//
// absPath must be absolute; callers pre-anchor relative paths against the
// appropriate root (process cwd for general use, workspace root for
// sandboxed tools). Returns an error if no existing ancestor is reachable
// (would imply a broken filesystem; "/" always Lstats fine on a sane host).
func CanonicalizePath(absPath string) (string, error) {
if !filepath.IsAbs(absPath) {
return "", fmt.Errorf("canonicalize: %q is not absolute", absPath)
}
ancestor := filepath.Clean(absPath)
tail := ""
for {
if _, err := os.Lstat(ancestor); err == nil {
break
}
parent := filepath.Dir(ancestor)
if parent == ancestor {
return "", fmt.Errorf("canonicalize %q: no existing ancestor", absPath)
}
tail = filepath.Join(filepath.Base(ancestor), tail)
ancestor = parent
}
canonicalAncestor, err := filepath.EvalSymlinks(ancestor)
if err != nil {
return "", fmt.Errorf("canonicalize ancestor of %q: %w", absPath, err)
}
if tail == "" {
return filepath.Clean(canonicalAncestor), nil
}
return filepath.Clean(filepath.Join(canonicalAncestor, tail)), nil
}