4b70e31cf4
Critical fixes:
- logs --job: validate ^[A-Za-z0-9_-]+$ + shellQuote (was %q backtick RCE)
- pprof: isLoopback treats empty host as bind-all (was :6060 bypass)
- backup restore: filepath.Rel containment check (was tar-slip via a/../..)
- WebAuthn reg auth deferred to P04 (requires session infra)
High fixes:
- txn rollback/show/apply: validate ^T-[0-9a-f]{16}$ + shellQuote
- nft diff --against: validate txn ID before filepath.Join
- drain stopAlloc: validate allocID ^[A-Za-z0-9_-]+$
- cluster_compat: shellQuote peer dir name
- podman image: shellQuote (was %q backtick injection)
- nft TrustedProbes: net.ParseIP/CIDR validation + split v4/v6 sets
- sudoers: validate --proxmox-user/--proxmox-role ^[a-zA-Z_][a-zA-Z0-9_-]{0,31}$
fixed path /etc/sudoers.d/orca; shellQuote pveum/useradd; validateSudoers
checks actual file
- nft country block: validate ^[A-Z]{2}$ (was len==2 only)
New file: internal/cli/validate.go (shared validators + shellQuote)
All 38 Go test packages pass. go vet + gofmt clean.
---ci---
project: orca
phase: 2
milestone: v0.13
status: complete
requirements:
covered: [150]
---/ci---
479 lines
13 KiB
Go
479 lines
13 KiB
Go
package backup
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func keyA() []byte { return []byte("0123456789abcdef0123456789abcdef") }
|
|
func keyB() []byte { return []byte("abcdef0123456789abcdef0123456789") }
|
|
|
|
func writeFiles(t *testing.T, root string, files map[string]string) {
|
|
t.Helper()
|
|
for name, body := range files {
|
|
p := filepath.Join(root, name)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
|
|
}
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", p, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func runBackup(t *testing.T, src, out string, key []byte) {
|
|
t.Helper()
|
|
if err := Backup(BackupOptions{
|
|
SourceDir: src,
|
|
OutputPath: out,
|
|
MasterKey: key,
|
|
}); err != nil {
|
|
t.Fatalf("Backup: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBackupRestoreRoundTrip(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
writeFiles(t, src, map[string]string{
|
|
"cluster/master.key": "KEYMATERIAL",
|
|
"_defaults/db/orca.db": "SQLITE",
|
|
"_defaults/.env": "FOO=bar",
|
|
"_defaults/jobs/job1.md": "job body",
|
|
"cluster/peers/host1/peer.json": "{}",
|
|
})
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if _, err := os.Stat(out + ".sig"); err != nil {
|
|
t.Fatalf("sig file missing: %v", err)
|
|
}
|
|
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
|
|
for name, body := range map[string]string{
|
|
"cluster/master.key": "KEYMATERIAL",
|
|
"_defaults/db/orca.db": "SQLITE",
|
|
"_defaults/.env": "FOO=bar",
|
|
"_defaults/jobs/job1.md": "job body",
|
|
"cluster/peers/host1/peer.json": "{}",
|
|
} {
|
|
got, err := os.ReadFile(filepath.Join(target, name))
|
|
if err != nil {
|
|
t.Errorf("restored file %s missing: %v", name, err)
|
|
continue
|
|
}
|
|
if string(got) != body {
|
|
t.Errorf("restored %s = %q, want %q", name, string(got), body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureSameKey(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
if err := VerifySignature(out, out+".sig", keyA()); err != nil {
|
|
t.Fatalf("verify same key: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureWrongKey(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
err := VerifySignature(out, out+".sig", keyB())
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("verify wrong key: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifySignatureTampered(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
body, err := os.ReadFile(out)
|
|
if err != nil {
|
|
t.Fatalf("read tarball: %v", err)
|
|
}
|
|
body[0] ^= 0xff
|
|
if err := os.WriteFile(out, body, 0o644); err != nil {
|
|
t.Fatalf("rewrite tampered tarball: %v", err)
|
|
}
|
|
err = VerifySignature(out, out+".sig", keyA())
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("verify tampered: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestExclusionSocketsAndRunOrca(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
writeFiles(t, src, map[string]string{
|
|
"keep.txt": "keep me",
|
|
"normal.db": "main db",
|
|
"sock-excluded.sock": "sock",
|
|
"side.db-wal": "wal",
|
|
"side.db-shm": "shm",
|
|
})
|
|
|
|
runOrcaDir := filepath.Join(src, "run", "orca")
|
|
if err := os.MkdirAll(runOrcaDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir run/orca: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(runOrcaDir, "ephemeral.txt"), []byte("eph"), 0o644); err != nil {
|
|
t.Fatalf("write ephemeral: %v", err)
|
|
}
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
|
|
for _, excluded := range []string{
|
|
"sock-excluded.sock",
|
|
"side.db-wal",
|
|
"side.db-shm",
|
|
"run/orca/ephemeral.txt",
|
|
} {
|
|
if _, err := os.Stat(filepath.Join(target, excluded)); !os.IsNotExist(err) {
|
|
t.Errorf("excluded file %s should not be in restore (err=%v)", excluded, err)
|
|
}
|
|
}
|
|
for _, kept := range []string{"keep.txt", "normal.db"} {
|
|
if _, err := os.Stat(filepath.Join(target, kept)); err != nil {
|
|
t.Errorf("kept file %s missing from restore: %v", kept, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreForceFalseRefusesNonEmpty(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(target, "existing.txt"), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("seed target: %v", err)
|
|
}
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
Force: false,
|
|
})
|
|
if !errors.Is(err, ErrTargetNotEmpty) {
|
|
t.Fatalf("restore to non-empty: got %v, want ErrTargetNotEmpty", err)
|
|
}
|
|
}
|
|
|
|
func TestRestoreForceTrueOverwritesNonEmpty(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "new"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(target, "stale.txt"), []byte("old"), 0o644); err != nil {
|
|
t.Fatalf("seed target: %v", err)
|
|
}
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
Force: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("restore force: %v", err)
|
|
}
|
|
got, err := os.ReadFile(filepath.Join(target, "a.txt"))
|
|
if err != nil {
|
|
t.Fatalf("restored a.txt missing: %v", err)
|
|
}
|
|
if string(got) != "new" {
|
|
t.Errorf("restored a.txt = %q, want %q", string(got), "new")
|
|
}
|
|
}
|
|
|
|
func TestEmptyBackup(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := VerifySignature(out, out+".sig", keyA()); err != nil {
|
|
t.Fatalf("verify empty backup: %v", err)
|
|
}
|
|
if err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyA(),
|
|
}); err != nil {
|
|
t.Fatalf("restore empty backup: %v", err)
|
|
}
|
|
entries, err := os.ReadDir(target)
|
|
if err != nil {
|
|
t.Fatalf("read target: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Errorf("empty backup restored %d entries, want 0", len(entries))
|
|
}
|
|
}
|
|
|
|
func TestRestoreSignatureMismatchFailsBeforeExtract(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
target := t.TempDir()
|
|
os.RemoveAll(target)
|
|
err := Restore(RestoreOptions{
|
|
InputPath: out,
|
|
TargetDir: target,
|
|
MasterKey: keyB(),
|
|
})
|
|
if !errors.Is(err, ErrSignatureMismatch) {
|
|
t.Fatalf("restore wrong key: got %v, want ErrSignatureMismatch", err)
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
entries, _ := os.ReadDir(target)
|
|
if len(entries) != 0 {
|
|
t.Errorf("target should be empty after failed verify, got %d entries", len(entries))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestoreBadSignatureContent(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
|
|
if err := os.WriteFile(out+".sig", []byte("not-hex!!"), 0o644); err != nil {
|
|
t.Fatalf("write bad sig: %v", err)
|
|
}
|
|
err := VerifySignature(out, out+".sig", keyA())
|
|
if err == nil {
|
|
t.Fatal("verify bad sig content: expected error, got nil")
|
|
}
|
|
if strings.Contains(err.Error(), "decode signature") {
|
|
return
|
|
}
|
|
if errors.Is(err, ErrSignatureMismatch) {
|
|
return
|
|
}
|
|
t.Errorf("verify bad sig content: got unexpected err %v", err)
|
|
}
|
|
|
|
func TestBackupSignatureFileContent(t *testing.T) {
|
|
src := t.TempDir()
|
|
out := filepath.Join(t.TempDir(), "b.tar.gz")
|
|
writeFiles(t, src, map[string]string{"a.txt": "hello"})
|
|
runBackup(t, src, out, keyA())
|
|
sig, err := os.ReadFile(out + ".sig")
|
|
if err != nil {
|
|
t.Fatalf("read sig: %v", err)
|
|
}
|
|
if dec, err := hexDecode(string(bytes.TrimSpace(sig))); err != nil {
|
|
t.Fatalf("sig not hex: %v", err)
|
|
} else if len(dec) != 32 {
|
|
t.Errorf("sig len = %d, want 32", len(dec))
|
|
}
|
|
}
|
|
|
|
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)")
|
|
}
|
|
}
|
|
|
|
// createCraftedTarballWithFile creates a tar.gz containing a single
|
|
// regular file entry with the given (possibly malicious) name. Used to
|
|
// test the tar-slip path-traversal guard (F3).
|
|
func createCraftedTarballWithFile(path, name, body 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.TypeReg,
|
|
Mode: 0o644,
|
|
Size: int64(len(body)),
|
|
}
|
|
if err := tw.WriteHeader(hdr); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tw.Write([]byte(body)); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TestRestoreRejectsTarSlipRegularFile verifies a tarball with a regular
|
|
// file entry whose name contains an embedded ".." traversal (e.g.
|
|
// "a/../../etc/passwd") is rejected. The old prefix-only check missed
|
|
// this pattern; the F3 filepath.Rel containment check catches it.
|
|
func TestRestoreRejectsTarSlipRegularFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
tarPath := filepath.Join(dir, "slip.tar.gz")
|
|
sigPath := tarPath + ".sig"
|
|
if err := createCraftedTarballWithFile(tarPath, "a/../../etc/passwd", "pwned"); err != nil {
|
|
t.Fatalf("create tarball: %v", err)
|
|
}
|
|
key := make([]byte, 32)
|
|
for i := range key {
|
|
key[i] = byte(i + 9)
|
|
}
|
|
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 tar-slip regular file (F3)")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsafe path") {
|
|
t.Errorf("error should mention unsafe path: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|