1f06029b70
Read-only deep sweep checkpoint. Tier 1 complete for controller+agent: - CTRL-001 (High): app-import path traversal via unvalidated manifest.AppName (failing evidence test) - AGENT-001/002/003, CTRL-002 (Medium): wipe TOCTOU, blkid-error classify, decrypt-before-MAC - 12 Low/Info hardening items + invariant checklist + contract/dead-code/test-gap sections Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.7 KiB
Go
68 lines
2.7 KiB
Go
package appexport
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestImportManifestAppNameTraversal is an AUDIT evidence test for finding
|
|
// [CTRL-001] (commit eea235b). It demonstrates that an imported bundle's
|
|
// manifest.AppName — which is fully attacker-controlled JSON inside the .fab —
|
|
// is used verbatim as a path segment in executeImport:
|
|
//
|
|
// stackDir := filepath.Join(stacksDir, manifest.AppName) // restore.go:339
|
|
// os.MkdirAll(stackDir, 0755) // restore.go:365
|
|
// composePath := filepath.Join(stackDir, "docker-compose.yml") // restore.go:401
|
|
//
|
|
// UnmarshalManifest performs NO validation of AppName (manifest.go:36-42), and
|
|
// no IsValidStackName/sanitizer exists in the package. A name containing ".."
|
|
// therefore escapes the stacks base directory.
|
|
//
|
|
// This test asserts the SAFE invariant ("the resolved stack dir must stay under
|
|
// the stacks base"). It FAILS at the recorded commit, which is the evidence the
|
|
// guard is missing. Do NOT "fix" the bug by weakening this test — the fix is to
|
|
// reject traversal AppNames in UnmarshalManifest / before the join.
|
|
func TestImportManifestAppNameTraversal(t *testing.T) {
|
|
const stacksDir = "/data/stacks" // stand-in for provider.GetStacksBaseDir()
|
|
|
|
cases := []struct {
|
|
name string
|
|
appName string
|
|
}{
|
|
{"parent-escape", "../evil"},
|
|
{"deep-escape", "../../etc/cron.d/x"},
|
|
{"absolute", "/etc/cron.d/x"},
|
|
}
|
|
|
|
base := filepath.Clean(stacksDir)
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Exactly mirror restore.go:339.
|
|
stackDir := filepath.Join(stacksDir, tc.appName)
|
|
|
|
// The invariant the importer SHOULD enforce: stackDir stays under base.
|
|
if stackDir != base && !strings.HasPrefix(stackDir, base+string(filepath.Separator)) {
|
|
t.Fatalf("CTRL-001: manifest.AppName %q escapes stacks base: filepath.Join(%q, AppName) = %q (outside %q). "+
|
|
"executeImport then os.MkdirAll's and writes app.yaml/docker-compose.yml there with no validation.",
|
|
tc.appName, stacksDir, stackDir, base)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestUnmarshalManifestDoesNotValidateAppName documents that the only manifest
|
|
// parse entrypoint accepts a hostile AppName without complaint — the missing
|
|
// chokepoint for [CTRL-001].
|
|
func TestUnmarshalManifestDoesNotValidateAppName(t *testing.T) {
|
|
raw := []byte(`{"version":1,"app_name":"../../escape","display_name":"x"}`)
|
|
m, err := UnmarshalManifest(raw)
|
|
if err != nil {
|
|
t.Fatalf("unexpected parse error: %v", err)
|
|
}
|
|
if strings.Contains(m.AppName, "..") {
|
|
t.Fatalf("CTRL-001: UnmarshalManifest returned a traversal AppName %q with no rejection; "+
|
|
"a sanitizer (single safe path segment, allowlist) is missing", m.AppName)
|
|
}
|
|
}
|