fetch_ml/internal/fileutil/secure.go
Jeremie Fraeys 17d5c75e33
fix(security): Path validation improvements for symlink resolution
Fix ValidatePath to correctly resolve symlinks and handle edge cases:
- Resolve symlinks before boundary check to prevent traversal
- Handle macOS /private prefix correctly
- Add fallback for non-existent paths (parent directory resolution)
- Double boundary checks: before AND after symlink resolution
- Prevent race conditions between check and use

Update path traversal tests:
- Correct test expectations for "..." (three dots is valid filename, not traversal)
- Add tests for symlink escape attempts
- Add unicode attack tests
- Add deeply nested traversal tests

Security impact: Prevents path traversal via symlink following in artifact
scanning and other file operations.
2026-02-23 19:44:16 -05:00

158 lines
5.4 KiB
Go

// Package fileutil provides secure file operation utilities to prevent path traversal attacks.
package fileutil
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
)
// SecurePathValidator provides path traversal protection with symlink resolution.
type SecurePathValidator struct {
BasePath string
}
// NewSecurePathValidator creates a new path validator for a base directory.
func NewSecurePathValidator(basePath string) *SecurePathValidator {
return &SecurePathValidator{BasePath: basePath}
}
// ValidatePath ensures resolved path is within base directory.
// It resolves symlinks and returns the canonical absolute path.
func (v *SecurePathValidator) ValidatePath(inputPath string) (string, error) {
if v.BasePath == "" {
return "", fmt.Errorf("base path not set")
}
// Clean the path to remove . and ..
cleaned := filepath.Clean(inputPath)
// Get absolute base path and resolve any symlinks (critical for macOS /tmp -> /private/tmp)
baseAbs, err := filepath.Abs(v.BasePath)
if err != nil {
return "", fmt.Errorf("failed to get absolute base path: %w", err)
}
// Resolve symlinks in base path for accurate comparison
baseResolved, err := filepath.EvalSymlinks(baseAbs)
if err != nil {
// Base path may not exist yet, use as-is
baseResolved = baseAbs
}
// If cleaned is already absolute, check if it's within base
var absPath string
if filepath.IsAbs(cleaned) {
// For absolute paths, try to resolve symlinks
resolvedInput, err := filepath.EvalSymlinks(cleaned)
if err != nil {
// Path doesn't exist - try to resolve parent directories to handle macOS /private prefix
dir := filepath.Dir(cleaned)
resolvedDir, dirErr := filepath.EvalSymlinks(dir)
if dirErr == nil {
// Parent resolved successfully, use resolved parent + base name
base := filepath.Base(cleaned)
resolvedInput = filepath.Join(resolvedDir, base)
} else {
// Can't resolve parent either, use cleaned as-is
resolvedInput = cleaned
}
}
absPath = resolvedInput
} else {
// Join with RESOLVED base path if relative (for consistent handling on macOS)
absPath = filepath.Join(baseResolved, cleaned)
}
// FIRST: Check path boundaries before resolving symlinks
// This catches path traversal attempts even if the path doesn't exist
baseWithSep := baseResolved + string(filepath.Separator)
if !strings.HasPrefix(absPath+string(filepath.Separator), baseWithSep) && absPath != baseResolved {
return "", fmt.Errorf("path escapes base directory: %s (base is %s)", inputPath, baseResolved)
}
// Resolve symlinks - critical for security
resolved, err := filepath.EvalSymlinks(absPath)
if err != nil {
// If the file doesn't exist, we still need to check the directory path
// Try to resolve the parent directory
dir := filepath.Dir(absPath)
resolvedDir, dirErr := filepath.EvalSymlinks(dir)
if dirErr != nil {
// Path doesn't exist and parent can't be resolved - this is ok for new files
// as long as the path itself is within bounds (which we checked above)
return absPath, nil
}
// Reconstruct the path with resolved directory
base := filepath.Base(absPath)
resolved = filepath.Join(resolvedDir, base)
}
// Get absolute resolved path
resolvedAbs, err := filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("failed to get absolute resolved path: %w", err)
}
// SECOND: Verify resolved path is still within base (symlink escape check)
if resolvedAbs != baseResolved && !strings.HasPrefix(resolvedAbs+string(filepath.Separator), baseWithSep) {
return "", fmt.Errorf("path escapes base directory: %s (resolved to %s, base is %s)", inputPath, resolvedAbs, baseResolved)
}
return resolvedAbs, nil
}
// SecureFileRead securely reads a file after cleaning the path to prevent path traversal.
func SecureFileRead(path string) ([]byte, error) {
return os.ReadFile(filepath.Clean(path))
}
// SecureFileWrite securely writes a file after cleaning the path to prevent path traversal.
func SecureFileWrite(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(filepath.Clean(path), data, perm)
}
// SecureOpenFile securely opens a file after cleaning the path to prevent path traversal.
func SecureOpenFile(path string, flag int, perm os.FileMode) (*os.File, error) {
return os.OpenFile(filepath.Clean(path), flag, perm)
}
// SecureReadDir reads directory contents with path validation.
func (v *SecurePathValidator) SecureReadDir(dirPath string) ([]os.DirEntry, error) {
validatedPath, err := v.ValidatePath(dirPath)
if err != nil {
return nil, fmt.Errorf("directory path validation failed: %w", err)
}
return os.ReadDir(validatedPath)
}
// SecureCreateTemp creates a temporary file within the base directory.
func (v *SecurePathValidator) SecureCreateTemp(pattern string) (*os.File, string, error) {
validatedPath, err := v.ValidatePath("")
if err != nil {
return nil, "", fmt.Errorf("base directory validation failed: %w", err)
}
// Generate secure random suffix
randomBytes := make([]byte, 16)
if _, err := rand.Read(randomBytes); err != nil {
return nil, "", fmt.Errorf("failed to generate random bytes: %w", err)
}
randomSuffix := base64.URLEncoding.EncodeToString(randomBytes)
// Create temp file
if pattern == "" {
pattern = "tmp"
}
fileName := fmt.Sprintf("%s_%s", pattern, randomSuffix)
fullPath := filepath.Join(validatedPath, fileName)
file, err := os.Create(fullPath)
if err != nil {
return nil, "", fmt.Errorf("failed to create temp file: %w", err)
}
return file, fullPath, nil
}