diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 0462058..1f9e834 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -310,6 +310,26 @@ func Restore(opts RestoreOptions) error { } continue case tar.TypeSymlink: + // REQ-127 / F7: validate Linkname to prevent symlink attacks. + // Reject absolute links, .. traversal, and links outside + // the target dir (which could point to /etc/shadow etc.). + link := hdr.Linkname + if link == "" { + return fmt.Errorf("restore: empty symlink linkname for %q", name) + } + if strings.HasPrefix(link, "/") { + return fmt.Errorf("restore: symlink %q has absolute linkname %q (REQ-127: path traversal)", name, link) + } + if strings.Contains(link, "..") { + // Resolve the link relative to the dest dir; if it + // escapes the target, reject. + linkDest := filepath.Join(filepath.Dir(dest), link) + linkClean := filepath.Clean(linkDest) + targetClean := filepath.Clean(target) + if !strings.HasPrefix(linkClean, targetClean+string(filepath.Separator)) && linkClean != targetClean { + return fmt.Errorf("restore: symlink %q linkname %q escapes target (REQ-127)", name, link) + } + } if err := os.Remove(dest); err != nil && !os.IsNotExist(err) { return fmt.Errorf("restore: clear symlink %s: %w", name, err) } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index 5c4657b..92e0a19 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -1,7 +1,11 @@ package backup import ( + "archive/tar" "bytes" + "compress/gzip" + "crypto/hmac" + "crypto/sha256" "encoding/hex" "errors" "os" @@ -314,3 +318,96 @@ func TestBackupSignatureFileContent(t *testing.T) { func hexDecode(s string) ([]byte, error) { return hex.DecodeString(s) } + +// --- REQ-127 / F7 backup symlink validation tests --- + +// TestRestoreRejectsAbsoluteSymlink verifies a tarball with an absolute +// symlink linkname is rejected. +func TestRestoreRejectsAbsoluteSymlink(t *testing.T) { + dir := t.TempDir() + // Create a crafted tarball with an absolute symlink. + tarPath := filepath.Join(dir, "evil.tar.gz") + sigPath := tarPath + ".sig" + if err := createCraftedTarball(tarPath, "link", "/etc/shadow"); err != nil { + t.Fatalf("create tarball: %v", err) + } + // Create a valid signature (the signature verifies, but the symlink + // validation should still reject the restore). + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + mac := hmac.New(sha256.New, key) + data, _ := os.ReadFile(tarPath) + mac.Write(data) + if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil { + t.Fatalf("write sig: %v", err) + } + target := filepath.Join(dir, "restore") + os.MkdirAll(target, 0o755) + err := Restore(RestoreOptions{ + InputPath: tarPath, + TargetDir: target, + MasterKey: key, + Force: true, + }) + if err == nil { + t.Fatal("Restore should reject absolute symlink (REQ-127)") + } + if !strings.Contains(err.Error(), "absolute") { + t.Errorf("error should mention absolute: %v", err) + } +} + +// TestRestoreRejectsTraversalSymlink verifies a tarball with a .. symlink +// that escapes the target is rejected. +func TestRestoreRejectsTraversalSymlink(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "evil2.tar.gz") + sigPath := tarPath + ".sig" + if err := createCraftedTarball(tarPath, "link", "../../etc/shadow"); err != nil { + t.Fatalf("create tarball: %v", err) + } + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + mac := hmac.New(sha256.New, key) + data, _ := os.ReadFile(tarPath) + mac.Write(data) + if err := os.WriteFile(sigPath, []byte(hex.EncodeToString(mac.Sum(nil))), 0o600); err != nil { + t.Fatalf("write sig: %v", err) + } + target := filepath.Join(dir, "restore2") + os.MkdirAll(target, 0o755) + err := Restore(RestoreOptions{ + InputPath: tarPath, + TargetDir: target, + MasterKey: key, + Force: true, + }) + if err == nil { + t.Fatal("Restore should reject traversal symlink (REQ-127)") + } +} + +// createCraftedTarball creates a tar.gz containing a single symlink +// entry with the given linkname. Used to test symlink validation. +func createCraftedTarball(path, name, linkname string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + gz := gzip.NewWriter(f) + defer gz.Close() + tw := tar.NewWriter(gz) + defer tw.Close() + hdr := &tar.Header{ + Name: name, + Typeflag: tar.TypeSymlink, + Linkname: linkname, + Mode: 0o644, + } + return tw.WriteHeader(hdr) +}