fix: harden auto backup filenames

This commit is contained in:
0xJacky
2026-07-15 20:17:09 +08:00
parent 91f7335837
commit f92218e010
4 changed files with 190 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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",

View File

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

71
model/auto_backup_test.go Normal file
View File

@@ -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)
}
})
}
}