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) } }