package admin import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "thehouseoficarus/internal/behavior" "thehouseoficarus/internal/config" "thehouseoficarus/internal/player" ) // TestEntityAPICannotEscapeData: entity create/rename IDs and directory // targets join into file paths, so a traversal ID ("../../x") must be // rejected instead of writing outside the data directory. func TestEntityAPICannotEscapeData(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "data") for _, sub := range []string{"objects", "players/accounts"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil { t.Fatal(err) } } accounts := player.NewAccountStore(dir) if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil { t.Fatal(err) } s, err := NewServer(ServerDeps{ Config: config.Default(), Accounts: accounts, DataDir: dir, ConfigPath: filepath.Join(dir, "config.yaml"), RunOnOwner: func(fn func()) bool { fn(); return true }, }) if err != nil { t.Fatal(err) } cookieRec := httptest.NewRecorder() s.setAuthCookie(cookieRec, "auditor") cookie := cookieRec.Result().Cookies()[0] post := func(path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.AddCookie(cookie) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec } // Traversal entity ID via the structured create route. if rec := post("/api/objects", `{"id":"../../outside","name":"Escaped object"}`); rec.Code != http.StatusBadRequest { t.Fatalf("traversal object id: status %d body %s", rec.Code, rec.Body.String()) } // Traversal new ID via the entity rename route: the rename handler // must reject the request, not rename anything. rec := post("/api/objects/rename", `{"id":"test","new_id":"../../outside"}`) if !strings.Contains(rec.Body.String(), "error") { t.Fatalf("traversal rename id not rejected: status %d body %s", rec.Code, rec.Body.String()) } // Traversal SOURCE id via the rename and move routes: the source is // joined into a path too, so it must be rejected before it can move a // file outside data/ (e.g. the server's own config.yaml). sentinel := filepath.Join(root, "sentinel.yaml") if err := os.WriteFile(sentinel, []byte("name: Sentinel\n"), 0o600); err != nil { t.Fatal(err) } for _, tc := range []struct{ path, body string }{ {"/api/objects/rename", `{"id":"../../sentinel","new_id":"stolen"}`}, {"/api/objects/move", `{"id":"../../sentinel","dir":"sub"}`}, } { if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "error") { t.Fatalf("traversal source id not rejected at %s: status %d body %s", tc.path, rec.Code, rec.Body.String()) } } if _, err := os.Stat(sentinel); err != nil { t.Fatalf("traversal source id moved a file outside data/: %v", err) } // Nothing may exist outside the data directory. if _, err := os.Stat(filepath.Join(root, "outside.yaml")); !os.IsNotExist(err) { t.Fatal("entity API escaped the data directory") } if _, err := os.Stat(filepath.Join(root, "stolen.yaml")); !os.IsNotExist(err) { t.Fatal("traversal rename escaped the data directory") } } // TestTechModuleCreateRejectsTraversalID: POST /api/techs and /api/modules // once joined an unvalidated id into a filesystem path, allowing an // arbitrary write outside the data directory (and a destructive undo). func TestTechModuleCreateRejectsTraversalID(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "data") for _, sub := range []string{"techs", "modules", "players/accounts"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil { t.Fatal(err) } } accounts := player.NewAccountStore(dir) if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true}); err != nil { t.Fatal(err) } s, err := NewServer(ServerDeps{ Config: config.Default(), Accounts: accounts, DataDir: dir, ConfigPath: filepath.Join(dir, "config.yaml"), RunOnOwner: func(fn func()) bool { fn(); return true }, }) if err != nil { t.Fatal(err) } cookieRec := httptest.NewRecorder() s.setAuthCookie(cookieRec, "auditor") cookie := cookieRec.Result().Cookies()[0] post := func(path, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) req.AddCookie(cookie) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec } for _, tc := range []struct{ path, body string }{ {"/api/techs", `{"id":"../../outside_tech","name":"x"}`}, {"/api/modules", `{"id":"../../outside_mod","name":"x"}`}, } { if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "invalid id") { t.Fatalf("%s traversal id not rejected: status %d body %s", tc.path, rec.Code, rec.Body.String()) } } if _, err := os.Stat(filepath.Join(root, "outside_tech.yaml")); !os.IsNotExist(err) { t.Fatal("tech create escaped the data directory") } if _, err := os.Stat(filepath.Join(root, "outside_mod.yaml")); !os.IsNotExist(err) { t.Fatal("module create escaped the data directory") } } // TestEntityRenameRejectsDuplicateIDInOtherDirectory: entity IDs are global // across the type's subdirectory tree (the runtime path indexes resolve by // filename anywhere under data/), so renaming into an ID that // exists in a DIFFERENT directory must be rejected instead of creating a // duplicate ID shadowed by whichever file the index finds first. func TestEntityRenameRejectsDuplicateIDInOtherDirectory(t *testing.T) { dir := t.TempDir() for _, sub := range []string{"items/a", "items/b"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(dir, "items/a/old.yaml"), []byte("name: Original\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "items/b/taken.yaml"), []byte("name: Existing\n"), 0o644); err != nil { t.Fatal(err) } if _, _, _, err := renameEntityFile(dir, "items", "old", "taken"); err == nil { t.Fatal("rename accepted an ID that already exists in another directory") } if dups := behavior.CheckDuplicateIDs(filepath.Join(dir, "items")); len(dups) != 0 { t.Fatalf("rename produced duplicate IDs: %+v", dups) } // Renaming within the same directory to a free name still works. if _, _, _, err := renameEntityFile(dir, "items", "old", "moved"); err != nil { t.Fatalf("ordinary rename rejected: %v", err) } }