9853a522e6
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.
107 lines
2.6 KiB
Go
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
|
|
}
|