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

107 lines
2.6 KiB
Go

package fs
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"somegit.dev/Owlibou/gnoma/internal/security"
)
var ErrOutsideWorkspace = errors.New("path outside workspace")
type Guard struct {
roots []string
}
func NewGuard(roots ...string) (*Guard, error) {
if len(roots) == 0 {
return nil, errors.New("guard requires at least one root")
}
resolved := make([]string, 0, len(roots))
for _, r := range roots {
if !filepath.IsAbs(r) {
return nil, fmt.Errorf("guard root %q must be absolute", r)
}
canonical, err := filepath.EvalSymlinks(r)
if err != nil {
return nil, fmt.Errorf("guard root %q: %w", r, err)
}
info, err := os.Stat(canonical)
if err != nil {
return nil, fmt.Errorf("guard root %q: %w", r, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("guard root %q is not a directory", r)
}
resolved = append(resolved, filepath.Clean(canonical))
}
return &Guard{roots: resolved}, nil
}
func (g *Guard) Roots() []string {
out := make([]string, len(g.roots))
copy(out, g.roots)
return out
}
func (g *Guard) ResolveRead(path string) (string, error) {
abs, err := g.absolutise(path)
if err != nil {
return "", err
}
canonical, err := filepath.EvalSymlinks(abs)
if err != nil {
return "", fmt.Errorf("resolve %q: %w", path, err)
}
if !g.contains(canonical) {
return "", fmt.Errorf("%w: %s", ErrOutsideWorkspace, path)
}
return canonical, nil
}
// ResolveWrite canonicalises the deepest existing ancestor so a symlinked
// parent escaping the workspace is rejected even when the leaf doesn't exist.
func (g *Guard) ResolveWrite(path string) (string, error) {
abs, err := g.absolutise(path)
if err != nil {
return "", err
}
resolved, err := security.CanonicalizePath(abs)
if err != nil {
return "", fmt.Errorf("resolve %q: %w", path, err)
}
if !g.contains(resolved) {
return "", fmt.Errorf("%w: %s", ErrOutsideWorkspace, path)
}
return resolved, nil
}
// absolutise anchors relative paths against the first root rather than process
// cwd, which may drift over the lifetime of the agent.
func (g *Guard) absolutise(path string) (string, error) {
if path == "" {
return "", errors.New("empty path")
}
if filepath.IsAbs(path) {
return filepath.Clean(path), nil
}
return filepath.Clean(filepath.Join(g.roots[0], path)), nil
}
// contains uses a separator boundary so "/ws-evil" is not considered inside "/ws".
func (g *Guard) contains(canonical string) bool {
for _, root := range g.roots {
if canonical == root {
return true
}
prefix := root + string(filepath.Separator)
if strings.HasPrefix(canonical, prefix) {
return true
}
}
return false
}