diff --git a/internal/txn/txn.go b/internal/txn/txn.go index 1740b10..7789a91 100644 --- a/internal/txn/txn.go +++ b/internal/txn/txn.go @@ -326,10 +326,41 @@ with open(state_path) as f: artifacts = json.load(f) if isinstance(artifacts, dict): artifacts = [artifacts] + +# REQ-121/F5: path allowlist. Only orca-managed paths may be written. +# This prevents a compromised manifest from overwriting arbitrary +# system files (e.g. /etc/shadow, /root/.ssh/authorized_keys). +ALLOWED_PREFIXES = ( + "/etc/orca/", + "/etc/traefik/orca", + "/etc/traefik/dynamic/orca", + "/etc/systemd/system/orca-", + "/etc/nftables.d/orca", + "/etc/syncthing/orca", +) +# Resolve symlinks + normalize to catch ../ traversal attempts. +def path_allowed(p): + if not p: + return False + # Reject any path containing .. (path traversal). + if ".." in p.split("/"): + return False + # Reject paths that are not absolute (relative could land anywhere). + if not p.startswith("/"): + return False + norm = os.path.normpath(p) + for prefix in ALLOWED_PREFIXES: + if norm == prefix or norm.startswith(prefix): + return True + return False + for a in artifacts: path = a.get("path") if not path: continue + if not path_allowed(path): + sys.stderr.write("apply: refusing to write disallowed path: %s\n" % path) + sys.exit(7) content = a.get("content", "") mode = a.get("mode", "0644") os.makedirs(os.path.dirname(path), exist_ok=True) diff --git a/internal/txn/txn_test.go b/internal/txn/txn_test.go index 8d9ba18..2eef100 100644 --- a/internal/txn/txn_test.go +++ b/internal/txn/txn_test.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "os" + "os/exec" + "path/filepath" "strings" "sync" "testing" @@ -394,3 +396,74 @@ func TestComputeTxnIDStable(t *testing.T) { t.Fatalf("computeTxnID: got %q want %q", id, want) } } + +// --- REQ-121 / F5 txn apply path allowlist tests --- + +// TestApplyScriptRejectsDisallowedPath verifies the generated apply.sh +// refuses to write paths outside the allowlist. We render a bundle +// with a disallowed path, extract the apply.sh, run it with a crafted +// desired-state.json, and assert it exits 7 (the refusal code) without +// writing the file. +func TestApplyScriptRejectsDisallowedPath(t *testing.T) { + if testing.Short() { + t.Skip("apply.sh exec test skipped in -short mode") + } + disallowed := []string{ + "/etc/shadow", + "/root/.ssh/authorized_keys", + "/etc/passwd", + "/tmp/pwned", + "/etc/orca/../../shadow", + "relative/path", + } + for _, p := range disallowed { + t.Run(p, func(t *testing.T) { + b := mustRender(t, []map[string]any{{"path": p, "content": "pwned"}}) + // Write apply.sh + desired-state.json to a temp dir. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, fileDesiredState), b.DesiredState, 0o600); err != nil { + t.Fatalf("write desired-state: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, fileApply), b.ApplyScript, 0o755); err != nil { + t.Fatalf("write apply.sh: %v", err) + } + cmd := exec.Command("bash", filepath.Join(dir, fileApply)) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("apply.sh should fail for path %s, got success; output: %s", p, out) + } + if !strings.Contains(string(out), "refusing to write disallowed path") { + t.Errorf("apply.sh output should mention refusal: %s", out) + } + }) + } +} + +// TestApplyScriptAllowsOrcaPaths verifies the allowed prefixes work. +func TestApplyScriptAllowsOrcaPaths(t *testing.T) { + if testing.Short() { + t.Skip("apply.sh exec test skipped in -short mode") + } + // We can't actually write to /etc/ in a test, so we verify the + // allowlist logic in the generated script by checking the script + // content contains the allowlist and the path_allowed function. + b := mustRender(t, []map[string]any{{"path": "/etc/orca/test"}}) + script := string(b.ApplyScript) + if !strings.Contains(script, "ALLOWED_PREFIXES") { + t.Error("apply.sh missing ALLOWED_PREFIXES") + } + if !strings.Contains(script, "path_allowed") { + t.Error("apply.sh missing path_allowed function") + } + for _, prefix := range []string{ + "/etc/orca/", + "/etc/traefik/orca", + "/etc/systemd/system/orca-", + "/etc/nftables.d/orca", + "/etc/syncthing/orca", + } { + if !strings.Contains(script, prefix) { + t.Errorf("apply.sh missing allowed prefix %s", prefix) + } + } +}