From e20bf33e2a00102192e5be66b178037aeca3a7b1 Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 18 Aug 2026 07:39:51 +0200 Subject: [PATCH] fix(backup): use platform private staging controls --- tools/tht/internal/backup/preflight.go | 26 ++-------- tools/tht/internal/backup/preflight_test.go | 5 +- .../internal/backup/preflight_unix_test.go | 48 +++++++++++++++++++ .../internal/backup/preflight_windows_test.go | 40 ++++++++++++++++ 4 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 tools/tht/internal/backup/preflight_unix_test.go create mode 100644 tools/tht/internal/backup/preflight_windows_test.go diff --git a/tools/tht/internal/backup/preflight.go b/tools/tht/internal/backup/preflight.go index ff299c6b..6ca1c2a6 100644 --- a/tools/tht/internal/backup/preflight.go +++ b/tools/tht/internal/backup/preflight.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/safeio" ) var archiveDrivePath = regexp.MustCompile(`^[A-Za-z]:/`) @@ -267,7 +268,7 @@ func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchiv if result.stagingRoot == "" || result.freeBytes == nil { return nil, errors.New("backup archive has no controlled staging reservation") } - if err := ensurePrivateStagingRoot(result.stagingRoot); err != nil { + if err := safeio.EnsurePrivateDirectory(result.stagingRoot); err != nil { return nil, fmt.Errorf("create private restore staging root: %w", err) } freeBytes, err := result.freeBytes(result.stagingRoot) @@ -281,7 +282,7 @@ func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchiv if err != nil { return nil, errors.New("create private restore staging directory") } - if err := os.Chmod(directory, 0o700); err != nil { + if err := safeio.ProtectPrivateDirectory(directory); err != nil { _ = os.Remove(directory) return nil, errors.New("protect private restore staging directory") } @@ -342,27 +343,6 @@ func (result PreflightResult) StageArchive(ctx context.Context) (_ *stagedArchiv return staged, nil } -func ensurePrivateStagingRoot(root string) error { - if !filepath.IsAbs(root) || filepath.Clean(root) != root { - return errors.New("restore staging root is invalid") - } - if err := os.Mkdir(root, 0o700); err != nil && !errors.Is(err, os.ErrExist) { - return errors.New("restore staging root is unavailable") - } - info, err := os.Lstat(root) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return errors.New("restore staging root is unsafe") - } - if err := os.Chmod(root, 0o700); err != nil { - return errors.New("restore staging root cannot be protected") - } - info, err = os.Lstat(root) - if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm() != 0o700 { - return errors.New("restore staging root protection is invalid") - } - return nil -} - // Close removes only the staging file and directory created by StageArchive. func (staged *stagedArchive) Close() error { if staged == nil { diff --git a/tools/tht/internal/backup/preflight_test.go b/tools/tht/internal/backup/preflight_test.go index 86d19466..a720ac91 100644 --- a/tools/tht/internal/backup/preflight_test.go +++ b/tools/tht/internal/backup/preflight_test.go @@ -524,7 +524,10 @@ type preflightRawArchiveEntry struct { func preflightTestInstallation(t *testing.T) config.Installation { t.Helper() - root := t.TempDir() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } return config.Installation{ Path: filepath.Join(root, "deploy", "local-dev", "thothii-installation.yaml"), ProjectDirectory: root, diff --git a/tools/tht/internal/backup/preflight_unix_test.go b/tools/tht/internal/backup/preflight_unix_test.go new file mode 100644 index 00000000..05c8d79e --- /dev/null +++ b/tools/tht/internal/backup/preflight_unix_test.go @@ -0,0 +1,48 @@ +//go:build !windows + +package backup + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" +) + +func TestStageArchiveRejectsSymlinkedInstallationAncestor(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + realProject := filepath.Join(root, "real-project") + linkedProject := filepath.Join(root, "linked-project") + if err := os.Mkdir(realProject, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(realProject, linkedProject); err != nil { + t.Fatal(err) + } + installation := config.Installation{ + Path: filepath.Join(linkedProject, "deploy", "local-dev", "thothii-installation.yaml"), + ProjectDirectory: linkedProject, + } + if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { + t.Fatal(err) + } + archive := filepath.Join(root, "valid.zip") + writePreflightArchive(t, archive, preflightArchiveSpec{ + entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}}, + }) + result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()) + if err != nil { + t.Fatal(err) + } + defer result.CloseArchive() + + if _, err := result.StageArchive(context.Background()); err == nil || !strings.Contains(err.Error(), "staging root") { + t.Fatalf("StageArchive() error = %v, want unsafe symlinked staging-root rejection", err) + } +} diff --git a/tools/tht/internal/backup/preflight_windows_test.go b/tools/tht/internal/backup/preflight_windows_test.go new file mode 100644 index 00000000..48cde248 --- /dev/null +++ b/tools/tht/internal/backup/preflight_windows_test.go @@ -0,0 +1,40 @@ +//go:build windows + +package backup + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/safeio" +) + +func TestStageArchiveProtectsWindowsStagingDirectoriesWithOwnerOnlyACLs(t *testing.T) { + installation := preflightTestInstallation(t) + if err := os.MkdirAll(installation.ControlDirectory(), 0o700); err != nil { + t.Fatal(err) + } + archive := filepath.Join(t.TempDir(), "valid.zip") + writePreflightArchive(t, archive, preflightArchiveSpec{ + entries: []preflightArchiveEntry{{path: "configuration/operator.env", body: []byte("safe")}}, + }) + result, err := Preflight(context.Background(), installation, PreflightRequest{Archive: archive, Confirm: true}, permissivePreflightDependencies()) + if err != nil { + t.Fatal(err) + } + defer result.CloseArchive() + + staged, err := result.StageArchive(context.Background()) + if err != nil { + t.Fatal(err) + } + defer staged.Close() + if err := safeio.ValidatePrivateDirectory(result.stagingRoot); err != nil { + t.Fatalf("staging root ACL = %v, want owner-only", err) + } + if err := safeio.ValidatePrivateDirectory(staged.directory); err != nil { + t.Fatalf("staged archive directory ACL = %v, want owner-only", err) + } +}