mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-09-02 22:56:13 +08:00
fix: harden auto backup filenames
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
71
model/auto_backup_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user