Harden backup and restore operations
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
)
|
||||
@@ -66,13 +67,16 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun
|
||||
result.CredentialFile = f.Name()
|
||||
if err := f.Chmod(0600); err != nil {
|
||||
f.Close()
|
||||
os.Remove(result.CredentialFile)
|
||||
return result, err
|
||||
}
|
||||
if _, err := f.WriteString(credential); err != nil {
|
||||
f.Close()
|
||||
os.Remove(result.CredentialFile)
|
||||
return result, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(result.CredentialFile)
|
||||
return result, err
|
||||
}
|
||||
options = append(options, "credentials="+result.CredentialFile)
|
||||
@@ -97,5 +101,10 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e
|
||||
if !mounted.Mounted {
|
||||
return nil
|
||||
}
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run()
|
||||
}
|
||||
|
||||
@@ -5,9 +5,42 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
)
|
||||
|
||||
func ValidateRsyncPaths(job model.Job) error {
|
||||
target, err := filepath.EvalSymlinks(job.Rsync.Target)
|
||||
if err != nil {
|
||||
return errors.New("validation: resolve rsync target: " + err.Error())
|
||||
}
|
||||
for _, source := range job.Sources {
|
||||
resolved, err := filepath.EvalSymlinks(source.Path)
|
||||
if err != nil {
|
||||
return errors.New("validation: resolve rsync source: " + err.Error())
|
||||
}
|
||||
if pathsContainEachOther(resolved, target) {
|
||||
return errors.New("validation: resolved rsync source and target must not contain each other")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathsContainEachOther(first, second string) bool {
|
||||
within := func(path, root string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
return within(first, second) || within(second, first)
|
||||
}
|
||||
|
||||
func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) {
|
||||
if inPlace {
|
||||
if !confirmed {
|
||||
return "", errors.New("validation: in-place restore requires explicit confirmation")
|
||||
}
|
||||
return string(os.PathSeparator), nil
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
return "", errors.New("validation: restore target must be absolute")
|
||||
}
|
||||
@@ -19,20 +52,13 @@ func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool)
|
||||
if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if !confirmed {
|
||||
return "", errors.New("validation: in-place restore requires explicit confirmation")
|
||||
}
|
||||
if inPlace {
|
||||
anchor := string(os.PathSeparator) + strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator))[0]
|
||||
if err := rejectSymlinks(anchor, clean); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func validateStagingRestoreTarget(target, restoreRoot string) error {
|
||||
allowedRoots := []string{filepath.Clean(restoreRoot), "/mnt/user", "/mnt/disks", "/mnt/remotes"}
|
||||
_ = restoreRoot // Configuration validation keeps this below one of the storage roots.
|
||||
allowedRoots := []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"}
|
||||
for _, root := range allowedRoots {
|
||||
if root == "." || root == "" {
|
||||
continue
|
||||
@@ -45,6 +71,13 @@ func validateStagingRestoreTarget(target, restoreRoot string) error {
|
||||
}
|
||||
|
||||
func rejectSymlinks(root, target string) error {
|
||||
if info, err := os.Lstat(root); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("validation: restore target traverses a symlink")
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
relative, err := filepath.Rel(root, target)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
)
|
||||
|
||||
func TestValidateRestoreTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
root := "/mnt/user/urbm-restores"
|
||||
target := filepath.Join(root, "task")
|
||||
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
|
||||
t.Fatalf("staging target rejected: %q %v", got, err)
|
||||
@@ -18,14 +20,30 @@ func TestValidateRestoreTarget(t *testing.T) {
|
||||
if _, err := ValidateRestoreTarget("/home/root/restore", root, false, false); err == nil {
|
||||
t.Fatal("staging target outside allowed restore roots accepted")
|
||||
}
|
||||
if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil {
|
||||
t.Fatal("protected target accepted")
|
||||
if got, err := ValidateRestoreTarget("/mnt/user/data", root, true, true); err != nil || got != "/" {
|
||||
t.Fatalf("confirmed in-place target = %q, %v", got, err)
|
||||
}
|
||||
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
|
||||
t.Fatal("unconfirmed in-place restore accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRsyncPathsRejectsSymlinkOverlap(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
source := filepath.Join(root, "source")
|
||||
if err := os.Mkdir(source, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(root, "target-link")
|
||||
if err := os.Symlink(source, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := model.Job{Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target}}
|
||||
if err := ValidateRsyncPaths(job); err == nil {
|
||||
t.Fatal("symlinked rsync target overlapping the source was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
real := filepath.Join(root, "real")
|
||||
@@ -36,7 +54,22 @@ func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ValidateRestoreTarget(filepath.Join(link, "task"), root, false, false); err == nil {
|
||||
if err := rejectSymlinks(root, filepath.Join(link, "task")); err == nil {
|
||||
t.Fatal("symlink traversal accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectSymlinksChecksRootItself(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
real := filepath.Join(base, "real")
|
||||
link := filepath.Join(base, "root-link")
|
||||
if err := os.Mkdir(real, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(real, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rejectSymlinks(link, filepath.Join(link, "task")); err == nil {
|
||||
t.Fatal("symlinked restore root accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,11 @@ func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
var first error
|
||||
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
|
||||
source := prepared.Stopped[i]
|
||||
|
||||
Reference in New Issue
Block a user