Harden backup and restore operations

This commit is contained in:
Mikei386
2026-07-13 19:47:43 +02:00
parent 6cbf170d65
commit bbf063c157
26 changed files with 914 additions and 184 deletions
+70 -4
View File
@@ -10,12 +10,15 @@ import (
)
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
var snapshotPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$`)
var safePathPattern = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
func ValidateConfig(c Config) error {
if c.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
}
repos := make(map[string]Repository, len(c.Repositories))
mountPoints := make(map[string]string)
for _, repo := range c.Repositories {
if err := ValidateRepository(repo); err != nil {
return fmt.Errorf("repository %q: %w", repo.ID, err)
@@ -24,6 +27,13 @@ func ValidateConfig(c Config) error {
return fmt.Errorf("duplicate repository id %q", repo.ID)
}
repos[repo.ID] = repo
if repo.Mount != nil && repo.Mount.Managed {
point := filepath.Clean(repo.Mount.MountPoint)
if previous, exists := mountPoints[point]; exists {
return fmt.Errorf("repositories %q and %q use the same managed mountPoint %q", previous, repo.ID, point)
}
mountPoints[point] = repo.ID
}
}
jobs := make(map[string]struct{}, len(c.Jobs))
for _, job := range c.Jobs {
@@ -50,8 +60,52 @@ func ValidateConfig(c Config) error {
}
notifications[target.ID] = struct{}{}
}
if !filepath.IsAbs(c.Settings.RestoreRoot) {
return errors.New("restoreRoot must be absolute")
restoreRoot := filepath.Clean(c.Settings.RestoreRoot)
if !filepath.IsAbs(restoreRoot) || !belowStorageRoot(restoreRoot) {
return errors.New("restoreRoot must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
if strings.TrimSpace(c.Settings.ResticPath) == "" || !filepath.IsAbs(c.Settings.ResticPath) {
return errors.New("resticPath must be an absolute path")
}
if strings.TrimSpace(c.Settings.RsyncPath) == "" || !filepath.IsAbs(c.Settings.RsyncPath) {
return errors.New("rsyncPath must be an absolute path")
}
if c.Settings.CatchUpWindowHrs < 0 || c.Settings.CatchUpWindowHrs > 24*31 {
return errors.New("catchUpWindowHours must be between 0 and 744")
}
if c.Settings.PersistentLogDir != "" {
logDir := filepath.Clean(c.Settings.PersistentLogDir)
if !filepath.IsAbs(logDir) || (!pathWithin(logDir, "/mnt/user") && !pathWithin(logDir, "/mnt/disks") && !pathWithin(logDir, "/mnt/remotes")) {
return errors.New("persistentLogDir must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
}
return nil
}
func ValidateRestoreTask(task RestoreTask) error {
if !idPattern.MatchString(task.RepositoryID) {
return errors.New("restore repositoryId is invalid")
}
if err := ValidateSnapshotID(task.SnapshotID); err != nil {
return err
}
if strings.ContainsRune(task.Target, '\x00') {
return errors.New("restore target contains a forbidden control character")
}
if task.InPlace && len(task.Includes) == 0 {
return errors.New("in-place restore requires at least one included path")
}
for _, include := range task.Includes {
if strings.ContainsAny(include, "\x00\r\n") || !filepath.IsAbs(include) || filepath.Clean(include) == "/" {
return fmt.Errorf("invalid restore include %q", include)
}
}
return nil
}
func ValidateSnapshotID(value string) error {
if !snapshotPattern.MatchString(value) {
return errors.New("restore snapshotId is invalid")
}
return nil
}
@@ -178,6 +232,15 @@ func pathWithin(path, root string) bool {
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func belowStorageRoot(path string) bool {
for _, root := range []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"} {
if filepath.Clean(path) != root && pathWithin(path, root) {
return true
}
}
return false
}
func ValidateRepository(r Repository) error {
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
return errors.New("invalid schemaVersion or id")
@@ -200,11 +263,14 @@ func ValidateRepository(r Repository) error {
if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
return errors.New("managed mount requires remote and absolute mountPoint")
}
if strings.HasPrefix(r.Mount.Remote, "-") || strings.ContainsAny(r.Mount.Remote, "\x00\r\n") {
return errors.New("managed mount remote contains forbidden characters")
}
}
if r.Type == RepositorySFTP && r.CredentialRef != "" {
knownHosts := r.Options["knownHostsPath"]
if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") {
return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace")
if !safePathPattern.MatchString(knownHosts) || filepath.Clean(knownHosts) != knownHosts {
return errors.New("SFTP key authentication requires a clean absolute knownHostsPath containing only letters, digits, dot, underscore, slash, and hyphen")
}
}
return nil