diff options
| author | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 05:31:09 -0400 |
| commit | 153acb7f644d755c45533b7c5311e3ffa47e8973 (patch) | |
| tree | 7645e6f7b65bc3830b078e3b9336d2e6b199d584 /internal/admin/hardening_security_test.go | |
| parent | 87d08f0151e643951769e323995f1c5ed89ee200 (diff) | |
| download | thehouseoficarus-153acb7f644d755c45533b7c5311e3ffa47e8973.tar.gz | |
fix: harden engine across casino, economy, concurrency, security, and content
Diffstat (limited to 'internal/admin/hardening_security_test.go')
| -rw-r--r-- | internal/admin/hardening_security_test.go | 289 |
1 files changed, 289 insertions, 0 deletions
diff --git a/internal/admin/hardening_security_test.go b/internal/admin/hardening_security_test.go new file mode 100644 index 0000000..a077983 --- /dev/null +++ b/internal/admin/hardening_security_test.go @@ -0,0 +1,289 @@ +package admin + +// Regression tests for the security-hardening stage: +// - undo history is written 0600 and never stores account password hashes +// - undo of a deleted account file recreates it 0600 +// - the unauthenticated /login route bounds its request body +// - /static/ requires auth (except the login stylesheet) and never lists +// - saving colors preserves unrelated config keys/comments and writes a backup +// - a trigger kind change records a before-image at the old path + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "thehouseoficarus/internal/config" +) + +func TestUndoHistoryRedactsSecretsAndIsOwnerOnly(t *testing.T) { + dir := t.TempDir() + accDir := filepath.Join(dir, "players", "accounts") + if err := os.MkdirAll(accDir, 0755); err != nil { + t.Fatal(err) + } + accPath := filepath.Join(accDir, "alice.yaml") + oldContent := "name: alice\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\nadmin: false\n" + newContent := "name: alice\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\nadmin: true\n" + if err := os.WriteFile(accPath, []byte(newContent), 0600); err != nil { + t.Fatal(err) + } + + us := NewUndoStack(dir) + // A history file left world-readable by a previous version must be tightened. + if err := os.WriteFile(filepath.Join(dir, ".admin_history.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + us.Push(ChangeDesc{ + Description: "edit account", + FilePath: accPath, + OldContent: []byte(oldContent), + NewContent: []byte(newContent), + }) + + histPath := filepath.Join(dir, ".admin_history.json") + info, err := os.Stat(histPath) + if err != nil { + t.Fatalf("history file not written: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("history file mode = %o, want 0600", info.Mode().Perm()) + } + raw, err := os.ReadFile(histPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), "argon2id:") || strings.Contains(string(raw), "deadbeef") { + t.Fatalf("history file leaked the password hash:\n%s", raw) + } + if !strings.Contains(string(raw), redactedAccountHash) { + t.Fatalf("history file missing redaction placeholder:\n%s", raw) + } + + // The in-memory change keeps the real bytes so an in-session undo still + // restores a working account. + ch := us.Undo() + if ch == nil { + t.Fatal("undo returned nil") + } + got, err := os.ReadFile(accPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "argon2id:") { + t.Fatalf("undo lost the real password hash:\n%s", got) + } +} + +func TestUndoRestoresDeletedAccountAsOwnerOnly(t *testing.T) { + dir := t.TempDir() + accPath := filepath.Join(dir, "players", "accounts", "bob.yaml") + if err := os.MkdirAll(filepath.Dir(accPath), 0755); err != nil { + t.Fatal(err) + } + content := []byte("name: bob\npassword_hash: argon2id:1:65536:2:c2FsdHNhbHRzYWx0c2E:deadbeef\n") + if err := os.WriteFile(accPath, content, 0600); err != nil { + t.Fatal(err) + } + us := NewUndoStack(dir) + us.Push(ChangeDesc{ + Description: "delete bob", + FilePath: accPath, + OldContent: content, + IsDelete: true, + }) + if err := os.Remove(accPath); err != nil { + t.Fatal(err) + } + if us.Undo() == nil { + t.Fatal("undo of delete returned nil") + } + info, err := os.Stat(accPath) + if err != nil { + t.Fatalf("account not recreated: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("recreated account mode = %o, want 0600", info.Mode().Perm()) + } +} + +func TestLoginRejectsOversizedBody(t *testing.T) { + s := &AdminServer{} + big := strings.Repeat("a", maxLoginBodyBytes+1) + r := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(big)) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.handleLogin(w, r) + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized login body status = %d, want 413", w.Code) + } +} + +func TestStaticRequiresAuthAndDoesNotList(t *testing.T) { + s := newSecurityTestServer(t) + + // admin.css is referenced by the unauthenticated login page, so it stays + // public. + css := httptest.NewRecorder() + s.ServeHTTP(css, httptest.NewRequest(http.MethodGet, "/static/admin.css", nil)) + if css.Code != http.StatusOK { + t.Fatalf("admin.css status = %d, want 200", css.Code) + } + + // Every other asset requires a session. + js := httptest.NewRecorder() + s.ServeHTTP(js, httptest.NewRequest(http.MethodGet, "/static/admin.js", nil)) + if js.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated admin.js status = %d, want 401", js.Code) + } + + // A directory request must never emit a listing. + dir := httptest.NewRecorder() + s.ServeHTTP(dir, httptest.NewRequest(http.MethodGet, "/static/", nil)) + if dir.Code == http.StatusOK { + t.Fatalf("GET /static/ served a directory listing:\n%s", dir.Body.String()) + } +} + +func newSecurityTestServer(t *testing.T) *AdminServer { + t.Helper() + h, _ := newEditorHarness(t) + s, err := NewServer(config.Default(), false, h.g.AccountStore, h.g.World, h.g.ItemStore, h.g.ObjectStore, + h.g.MobStore, h.dir, h.g.CourseStore.ReloadAll, h.g.MigrateRoomIDsOnOwner, h.g.EvictCasinoRoom, + h.g.RebuildCraftIndex, filepath.Join(h.dir, "config.yaml"), h.g.RunAdminOnOwner) + if err != nil { + t.Fatal(err) + } + return s +} + +func TestFileEditorRefusesOnlinePlayerFile(t *testing.T) { + dir := t.TempDir() + accPath := filepath.Join(dir, "players", "accounts", "alice.yaml") + if err := os.MkdirAll(filepath.Dir(accPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(accPath, []byte("name: alice\npassword_hash: x\n"), 0600); err != nil { + t.Fatal(err) + } + s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)} + s.SetPlayerFileOnlineCheck(func(relPath string) bool { + return strings.HasPrefix(relPath, "players/accounts/") + }) + + r := httptest.NewRequest(http.MethodPut, "/api/files?path=players/accounts/alice.yaml", + strings.NewReader("name: alice\npassword_hash: y\n")) + w := httptest.NewRecorder() + s.handleFiles(w, r) + if !strings.Contains(w.Body.String(), "online") { + t.Fatalf("online player file edit not refused: %s", w.Body.String()) + } + got, err := os.ReadFile(accPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "password_hash: y") { + t.Fatalf("online account file was overwritten:\n%s", got) + } +} + +func TestNewAccountFileIsOwnerOnly(t *testing.T) { + dir := t.TempDir() + s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)} + r := httptest.NewRequest(http.MethodPut, "/api/files?path=players/accounts/newbie.yaml", + strings.NewReader("name: newbie\npassword_hash: x\n")) + w := httptest.NewRecorder() + s.handleFiles(w, r) + info, err := os.Stat(filepath.Join(dir, "players", "accounts", "newbie.yaml")) + if err != nil { + t.Fatalf("account file not created: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("new account file mode = %o, want 0600", info.Mode().Perm()) + } +} + +func TestColorsSavePatchesOnlyColorMaps(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + orig := "# operator note\ntick_length: 900\ndefault_colors:\n room_name: \"74 bold\"\nconstant_colors:\n hp_bar_low: \"A7\"\n" + if err := os.WriteFile(path, []byte(orig), 0644); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(path) + if err != nil { + t.Fatal(err) + } + s := &AdminServer{cfg: loaded, configPath: path} + + body := map[string]any{"colors": map[string]string{"room_name": "FF bold"}} + b, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/colors", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.saveColors(w, req) + if w.Code != http.StatusOK { + t.Fatalf("save status = %d: %s", w.Code, w.Body.String()) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + out := string(raw) + if !strings.Contains(out, "tick_length: 900") { + t.Fatalf("unrelated config key was reverted:\n%s", out) + } + if !strings.Contains(out, "# operator note") { + t.Fatalf("config comment was dropped:\n%s", out) + } + if !strings.Contains(out, "FF bold") { + t.Fatalf("color edit not persisted:\n%s", out) + } + if _, err := os.Stat(path + ".bak"); err != nil { + t.Fatalf("no config backup written: %v", err) + } +} + +func TestTriggerKindChangeUndoRestoresOldPath(t *testing.T) { + dir := t.TempDir() + root := filepath.Join(dir, "triggers") + oldPath := filepath.Join(root, "player_flag", "t1.yaml") + if err := os.MkdirAll(filepath.Dir(oldPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(oldPath, []byte("on_player_flag: visited\nsteps: []\n"), 0644); err != nil { + t.Fatal(err) + } + s := &AdminServer{dataDir: dir, undoStack: NewUndoStack(dir)} + + body := `{"on_global_flag":"world_event","steps":[]}` + req := httptest.NewRequest(http.MethodPut, "/api/triggers/t1", strings.NewReader(body)) + w := httptest.NewRecorder() + s.handleTriggerByID(w, req) + if w.Code != http.StatusOK { + t.Fatalf("put status = %d: %s", w.Code, w.Body.String()) + } + newPath := filepath.Join(root, "global_flag", "t1.yaml") + if _, err := os.Stat(newPath); err != nil { + t.Fatalf("new trigger file missing: %v", err) + } + if _, err := os.Stat(oldPath); !os.IsNotExist(err) { + t.Fatalf("old trigger file not removed after kind change") + } + + if s.undoStack.Undo() == nil { + t.Fatal("undo returned nil") + } + if _, err := os.Stat(oldPath); err != nil { + t.Fatalf("undo did not restore the old path: %v", err) + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Fatalf("undo left the new path behind") + } +} |
