package config import ( "fmt" "io" "os" "path/filepath" "regexp" "strings" "time" "unicode" ) // 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 := GetMPVConfigBackupDir() 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, rejecting symlinks before following them. // os.Lstat does not follow symlinks, so a symlink at the final path component is detected. var absBackupPath string fileInfo, err := os.Lstat(cleanPath) if err == nil && fileInfo.Mode()&os.ModeSymlink != 0 { // File is a symlink - reject it outright; backups must be regular files return fmt.Errorf("invalid backup path: symlinks are not allowed") } 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. // Pattern: legacy YYYY-MM-DD-HHMMSS_mpv.conf or the current durable, // collision-free YYYY-MM-DD-HHMMSS.nnnnnnnnn-_mpv.conf. filename := filepath.Base(absBackupPath) matched, _ := regexp.MatchString(`^\d{4}-\d{2}-\d{2}-\d{6}(?:\.\d{9}-[A-Za-z0-9]+)?_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) } // Backups are flat files directly inside conf_backups. Requiring exact // parent equality removes the intermediate-subdirectory/symlink ambiguity // that a prefix/Rel containment check permits. if filepath.Clean(filepath.Dir(absBackupPath)) != filepath.Clean(absBackupDir) { return fmt.Errorf("invalid backup path: outside allowed directory") } configDir := filepath.Dir(absBackupDir) for _, directory := range []string{configDir, absBackupDir} { info, err := os.Lstat(directory) if err != nil { return fmt.Errorf("invalid backup path: inspect parent: %w", err) } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("invalid backup path: parent is not a real directory") } } return nil } // ReadBackupFile validates and reads a flat backup through descriptor-relative // roots. A concurrent symlink swap cannot redirect the read outside the active // MPV config directory. func ReadBackupFile(backupPath string) ([]byte, error) { var content []byte err := withBackupRoot(backupPath, func(root *os.Root, name string) error { file, err := root.Open(name) if err != nil { return err } defer file.Close() const maxBackupBytes = 16 << 20 content, err = io.ReadAll(io.LimitReader(file, maxBackupBytes+1)) if err != nil { return err } if len(content) > maxBackupBytes { return fmt.Errorf("backup exceeds %d bytes", maxBackupBytes) } return nil }) return content, err } // RemoveBackupFile removes a validated regular backup through the same // descriptor-relative boundary used for restore reads. func RemoveBackupFile(backupPath string) error { return withBackupRoot(backupPath, func(root *os.Root, name string) error { return root.Remove(name) }) } func withBackupRoot(backupPath string, use func(*os.Root, string) error) error { if err := ValidateBackupPath(backupPath); err != nil { return err } backupDir, err := GetMPVConfigBackupDir() if err != nil { return err } absBackupDir, err := filepath.Abs(backupDir) if err != nil { return err } configRoot, err := os.OpenRoot(filepath.Dir(absBackupDir)) if err != nil { return fmt.Errorf("open MPV config root: %w", err) } defer configRoot.Close() backupInfo, err := configRoot.Lstat(filepath.Base(absBackupDir)) if err != nil { return fmt.Errorf("inspect backup directory: %w", err) } if !backupInfo.IsDir() || backupInfo.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("backup directory is not a real directory") } root, err := configRoot.OpenRoot(filepath.Base(absBackupDir)) if err != nil { return fmt.Errorf("open backup directory: %w", err) } defer root.Close() name := filepath.Base(backupPath) info, err := root.Lstat(name) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("backup file not found: %w", err) } return fmt.Errorf("inspect backup file: %w", err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("backup is not a regular file") } return use(root, name) } // ValidateCustomInstallPath validates a user-supplied custom install directory. // An empty path is invalid here - callers treat empty as "reset to default" // and handle that case separately. func ValidateCustomInstallPath(path string) error { if path == "" { return fmt.Errorf("install path cannot be empty") } // Reject control characters (newlines, tabs, NUL, etc.) for _, r := range path { if unicode.IsControl(r) { return fmt.Errorf("install path contains invalid control characters") } } cleanPath := filepath.Clean(path) if !filepath.IsAbs(cleanPath) { return fmt.Errorf("install path must be an absolute path: %s", path) } // Reject filesystem roots ("/", "C:\", ...) - a root is its own parent if filepath.Dir(cleanPath) == cleanPath { return fmt.Errorf("install path cannot be a filesystem root: %s", path) } // On Windows, reject anything under %WINDIR% (e.g. C:\Windows) if windir := os.Getenv("WINDIR"); windir != "" { cleanWindir := filepath.Clean(windir) if strings.EqualFold(cleanPath, cleanWindir) || strings.HasPrefix(strings.ToLower(cleanPath), strings.ToLower(cleanWindir)+string(filepath.Separator)) { return fmt.Errorf("install path cannot be inside the Windows directory: %s", path) } } // Verify the path is creatable by probing the nearest existing ancestor return checkPathCreatable(cleanPath) } // checkPathCreatable verifies that path can be created: it walks up to the // nearest existing ancestor and confirms it is a writable directory by // creating and immediately removing a probe directory. func checkPathCreatable(path string) error { ancestor := path for { info, err := os.Stat(ancestor) if err == nil { if !info.IsDir() { return fmt.Errorf("a file already exists at: %s", ancestor) } break } if !os.IsNotExist(err) { return fmt.Errorf("cannot access %s: %w", ancestor, err) } parent := filepath.Dir(ancestor) if parent == ancestor { return fmt.Errorf("no existing ancestor found for: %s", path) } ancestor = parent } probe, err := os.MkdirTemp(ancestor, ".mpv-manager-probe-*") if err != nil { return fmt.Errorf("install path is not writable: %s", path) } os.Remove(probe) return nil }