Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfacfea377 |
@@ -326,10 +326,41 @@ with open(state_path) as f:
|
|||||||
artifacts = json.load(f)
|
artifacts = json.load(f)
|
||||||
if isinstance(artifacts, dict):
|
if isinstance(artifacts, dict):
|
||||||
artifacts = [artifacts]
|
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:
|
for a in artifacts:
|
||||||
path = a.get("path")
|
path = a.get("path")
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
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", "")
|
content = a.get("content", "")
|
||||||
mode = a.get("mode", "0644")
|
mode = a.get("mode", "0644")
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -394,3 +396,74 @@ func TestComputeTxnIDStable(t *testing.T) {
|
|||||||
t.Fatalf("computeTxnID: got %q want %q", id, want)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user