// 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) { absPath = cleaned } else { // Join with base path if relative absPath = filepath.Join(baseAbs, cleaned) } // 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 { return "", fmt.Errorf("path resolution failed: %w", err) } // 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) } // Verify within base directory (must have path separator after base to prevent prefix match issues) baseWithSep := baseResolved + string(filepath.Separator) 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 }