package config import ( "fmt" "os" "path/filepath" "regexp" "strings" "time" "gitgud.io/mike/mpv-manager/pkg/constants" ) // ValidateBackupPath validates that a backup path is within the allowed backup directory // and has a valid filename format. This prevents path traversal attacks. func ValidateBackupPath(backupPath string) error { // Step 1: Clean path to resolve ".." sequences cleanPath := filepath.Clean(backupPath) // Step 2: Get expected backup directory backupDir, err := constants.GetMPVConfigBackupDirWithHome() if err != nil { return fmt.Errorf("failed to get backup directory: %w", err) } // Step 3: Get absolute path for backup directory absBackupDir, err := filepath.Abs(backupDir) if err != nil { return fmt.Errorf("failed to get absolute backup directory") } // Step 4: Get absolute path for backup and resolve symlinks if file exists var absBackupPath string fileInfo, err := os.Stat(cleanPath) if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 { // File is a symlink - resolve it for security absBackupPath, err = filepath.EvalSymlinks(cleanPath) if err != nil { return fmt.Errorf("failed to resolve backup path") } } else if err == nil { // Regular file exists - get absolute path absBackupPath, err = filepath.Abs(cleanPath) if err != nil { return fmt.Errorf("failed to resolve backup path") } } else if os.IsNotExist(err) { // File doesn't exist - get absolute path absBackupPath, err = filepath.Abs(cleanPath) if err != nil { return fmt.Errorf("failed to resolve backup path") } } else { // Other error (permission denied, etc.) return fmt.Errorf("failed to resolve backup path") } // Step 5: Extract and validate filename pattern FIRST // Pattern: YYYY-MM-DD-HHMMSS_mpv.conf filename := filepath.Base(absBackupPath) matched, _ := regexp.MatchString(`^\d{4}-\d{2}-\d{2}-\d{6}_mpv\.conf$`, filename) if !matched { return fmt.Errorf("invalid backup filename: %s", filename) } // Step 6: Validate timestamp values // Extract timestamp from filename: YYYY-MM-DD-HHMMSS timestampStr := filename[:17] // "2026-01-31-120000" timestampStr = strings.ReplaceAll(timestampStr, "-", "") // "20260131120000" // Parse as layout: "20060102150405" (YYYYMMDDHHmmss) // Use ParseInLocation with Local to match time.Now() behavior timestamp, err := time.ParseInLocation("20060102150405", timestampStr, time.Local) if err != nil { return fmt.Errorf("invalid timestamp in filename: %s", filename) } // Validate timestamp is reasonable (not in future, not too old) now := time.Now() maxFuture := 24 * time.Hour // Allow 24 hours into future (for timezone differences) maxPast := 365 * 24 * time.Hour // Allow 1 year in past // Reject timestamps beyond 24 hours in the future if timestamp.After(now.Add(maxFuture)) { return fmt.Errorf("backup timestamp is too far in the future: %s", filename) } // Reject timestamps more than 365 days in the past // Subtract 1 second from boundary to account for fractional second loss during formatting/parsing if timestamp.Before(now.Add(-maxPast - time.Second)) { return fmt.Errorf("backup timestamp is too old: %s", filename) } // Step 7: Validate path is subdirectory (AFTER filename validation to get proper error messages) relPath, err := filepath.Rel(absBackupDir, absBackupPath) if err != nil || strings.HasPrefix(relPath, "..") { return fmt.Errorf("invalid backup path: outside allowed directory") } return nil }