Harden backup and restore operations
This commit is contained in:
+33
-32
@@ -138,38 +138,39 @@ type NotificationTarget struct {
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
TaskType string `json:"taskType"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||
RetentionBefore int `json:"retentionBefore,omitempty"`
|
||||
RetentionAfter int `json:"retentionAfter,omitempty"`
|
||||
RetentionRemoved int `json:"retentionRemoved,omitempty"`
|
||||
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
|
||||
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
|
||||
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
|
||||
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
||||
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
||||
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
||||
ProgressFiles int64 `json:"progressFiles,omitempty"`
|
||||
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
|
||||
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
|
||||
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
|
||||
CurrentFile string `json:"currentFile,omitempty"`
|
||||
LiveLog []string `json:"liveLog,omitempty"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
TaskType string `json:"taskType"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||
RetentionBefore int `json:"retentionBefore,omitempty"`
|
||||
RetentionAfter int `json:"retentionAfter,omitempty"`
|
||||
RetentionRemoved int `json:"retentionRemoved,omitempty"`
|
||||
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
|
||||
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
|
||||
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
|
||||
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
||||
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
||||
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
||||
ProgressFiles int64 `json:"progressFiles,omitempty"`
|
||||
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
|
||||
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
|
||||
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
|
||||
CurrentFile string `json:"currentFile,omitempty"`
|
||||
LiveLog []string `json:"liveLog,omitempty"`
|
||||
Restore *RestoreTask `json:"restore,omitempty"`
|
||||
}
|
||||
|
||||
type RestoreTask struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,3 +118,39 @@ func TestRsyncJobRejectsOverlappingPaths(t *testing.T) {
|
||||
t.Fatal("rsync target inside source accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRestoreTaskRejectsDelimiterInjection(t *testing.T) {
|
||||
task := RestoreTask{RepositoryID: "repo", SnapshotID: "abc123\x00/", Target: "/mnt/user/restore", Includes: []string{"/mnt/user/data"}}
|
||||
if err := ValidateRestoreTask(task); err == nil {
|
||||
t.Fatal("NUL-delimited snapshot injection accepted")
|
||||
}
|
||||
task.SnapshotID = "abc123"
|
||||
task.InPlace = true
|
||||
task.Includes = nil
|
||||
if err := ValidateRestoreTask(task); err == nil {
|
||||
t.Fatal("in-place restore without an include accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigRejectsDuplicateManagedMountPoint(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Repositories = []Repository{
|
||||
{SchemaVersion: 1, ID: "one", Name: "One", Type: RepositorySMB, Location: "/mnt/remotes/shared", PasswordRef: "one-password", Mount: &MountConfig{Managed: true, Remote: "//one/share", MountPoint: "/mnt/remotes/shared"}},
|
||||
{SchemaVersion: 1, ID: "two", Name: "Two", Type: RepositoryNFS, Location: "/mnt/remotes/shared", PasswordRef: "two-password", Mount: &MountConfig{Managed: true, Remote: "two:/share", MountPoint: "/mnt/remotes/shared"}},
|
||||
}
|
||||
if err := ValidateConfig(c); err == nil {
|
||||
t.Fatal("duplicate managed mount point accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Settings.RestoreRoot = "/etc/urbm-restores"
|
||||
if err := ValidateConfig(c); err == nil {
|
||||
t.Fatal("restoreRoot outside Unraid storage roots accepted")
|
||||
}
|
||||
c.Settings.RestoreRoot = "/mnt/user/urbm-restores"
|
||||
if err := ValidateConfig(c); err != nil {
|
||||
t.Fatalf("safe restoreRoot rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user