diff --git a/internal/backup/auto_backup.go b/internal/backup/auto_backup.go index a7f2892b..1f9118bf 100644 --- a/internal/backup/auto_backup.go +++ b/internal/backup/auto_backup.go @@ -323,6 +323,12 @@ func validateAutoBackupFilename(filename string) error { if strings.HasPrefix(filename, `\\`) { return cosy.WrapErrorWithParams(ErrAutoBackupInvalidFilename, "filename contains Windows UNC prefix") } + if strings.ContainsAny(filename, `<>:"|?*`) { + return cosy.WrapErrorWithParams(ErrAutoBackupInvalidFilename, "filename contains Windows reserved characters") + } + if isWindowsReservedFilename(filename) { + return cosy.WrapErrorWithParams(ErrAutoBackupInvalidFilename, "filename uses a Windows reserved name") + } if strings.ContainsAny(filename, `/\`) { return cosy.WrapErrorWithParams(ErrAutoBackupInvalidFilename, "filename must not contain path separators") } @@ -348,6 +354,22 @@ func hasWindowsDrivePrefix(value string) bool { return (first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z') } +func isWindowsReservedFilename(filename string) bool { + baseName := filename + if dotIndex := strings.IndexByte(baseName, '.'); dotIndex >= 0 { + baseName = baseName[:dotIndex] + } + + switch strings.ToUpper(baseName) { + case "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": + return true + default: + return false + } +} + func isPathInsideBaseDir(baseDir, targetPath string) bool { relPath, err := filepath.Rel(baseDir, targetPath) if err != nil { diff --git a/internal/backup/auto_backup_test.go b/internal/backup/auto_backup_test.go index d2a821fa..66e96577 100644 --- a/internal/backup/auto_backup_test.go +++ b/internal/backup/auto_backup_test.go @@ -41,6 +41,14 @@ func TestResolveAutoBackupOutputPathRejectsTraversalFilenames(t *testing.T) { name: "windows drive relative path", filename: "C:evil.zip", }, + { + name: "windows reserved character", + filename: "evil:name.zip", + }, + { + name: "windows reserved device", + filename: "CON.zip", + }, { name: "windows unc path", filename: `\\server\share\evil.zip`, @@ -88,6 +96,21 @@ func TestBuildAutoBackupOutputPathAllowsGeneratedAndSimpleFilenames(t *testing.T assertPathInsideBaseDir(t, baseDir, simplePath) } +func TestBuildAutoBackupOutputPathUsesSanitizedAutoBackupName(t *testing.T) { + baseDir := t.TempDir() + autoBackup := &model.AutoBackup{ + Name: `..\evil/CON:`, + StorageType: model.StorageTypeLocal, + StoragePath: baseDir, + } + + generatedFilename := fmt.Sprintf("%s_%d.zip", autoBackup.GetName(), int64(123)) + generatedPath, err := buildAutoBackupOutputPath(autoBackup, generatedFilename) + assert.NoError(t, err) + assert.Equal(t, filepath.Join(baseDir, "evil_CON_123.zip"), generatedPath) + assertPathInsideBaseDir(t, baseDir, generatedPath) +} + func TestValidateAutoBackupConfigRejectsUnsafeNames(t *testing.T) { tests := []struct { name string @@ -117,6 +140,22 @@ func TestValidateAutoBackupConfigRejectsUnsafeNames(t *testing.T) { name: "windows drive relative path", backupName: "C:evil.zip", }, + { + name: "windows reserved character", + backupName: "backup:name", + }, + { + name: "windows reserved wildcard", + backupName: "backup*name", + }, + { + name: "windows reserved device", + backupName: "CON", + }, + { + name: "windows reserved device with extension", + backupName: "con.txt", + }, { name: "embedded traversal", backupName: "evil..zip", diff --git a/model/auto_backup.go b/model/auto_backup.go index f5be75d9..e7f55991 100644 --- a/model/auto_backup.go +++ b/model/auto_backup.go @@ -3,6 +3,7 @@ package model import ( "strings" "time" + "unicode" ) // BackupType represents the type of backup @@ -53,5 +54,61 @@ type AutoBackup struct { } func (a *AutoBackup) GetName() string { - return strings.ReplaceAll(strings.TrimSpace(a.Name), " ", "_") + return SafeAutoBackupName(a.Name) +} + +func SafeAutoBackupName(name string) string { + const fallbackName = "backup" + + var builder strings.Builder + lastUnderscore := false + + for _, r := range strings.TrimSpace(name) { + if isSafeAutoBackupNameRune(r) { + builder.WriteRune(r) + lastUnderscore = false + continue + } + + if !lastUnderscore { + builder.WriteByte('_') + lastUnderscore = true + } + } + + safeName := builder.String() + for strings.Contains(safeName, "..") { + safeName = strings.ReplaceAll(safeName, "..", ".") + } + + safeName = strings.Trim(safeName, "._-") + if safeName == "" { + return fallbackName + } + + if isReservedWindowsFilename(safeName) { + return "_" + safeName + } + + return safeName +} + +func isSafeAutoBackupNameRune(r rune) bool { + return r == '.' || r == '-' || r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) +} + +func isReservedWindowsFilename(name string) bool { + baseName := name + if dotIndex := strings.IndexByte(baseName, '.'); dotIndex >= 0 { + baseName = baseName[:dotIndex] + } + + switch strings.ToUpper(baseName) { + case "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9": + return true + default: + return false + } } diff --git a/model/auto_backup_test.go b/model/auto_backup_test.go new file mode 100644 index 00000000..930b58c8 --- /dev/null +++ b/model/auto_backup_test.go @@ -0,0 +1,71 @@ +package model + +import "testing" + +func TestAutoBackupGetNameSanitizesUnsafeFilenameComponents(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + { + name: "spaces", + in: "daily backup", + want: "daily_backup", + }, + { + name: "unix separators and traversal", + in: "../daily/backup", + want: "daily_backup", + }, + { + name: "windows separators and traversal", + in: `..\daily\backup`, + want: "daily_backup", + }, + { + name: "windows drive prefix", + in: `C:\temp\backup`, + want: "C_temp_backup", + }, + { + name: "windows reserved characters", + in: `daily:backup*2026?`, + want: "daily_backup_2026", + }, + { + name: "embedded traversal", + in: "daily..backup", + want: "daily.backup", + }, + { + name: "windows reserved device", + in: "CON", + want: "_CON", + }, + { + name: "windows reserved device with extension", + in: "con.txt", + want: "_con.txt", + }, + { + name: "unicode letters", + in: "每日 备份", + want: "每日_备份", + }, + { + name: "empty after sanitization", + in: " ../.. ", + want: "backup", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + autoBackup := &AutoBackup{Name: tt.in} + if got := autoBackup.GetName(); got != tt.want { + t.Fatalf("GetName() = %q, want %q", got, tt.want) + } + }) + } +}