// 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 }