diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/admin/api_drops.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_entity_path_test.go | 62 | ||||
| -rw-r--r-- | internal/admin/api_hazards.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_helpers.go | 15 | ||||
| -rw-r--r-- | internal/admin/api_items.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_mobs.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_modules.go | 4 | ||||
| -rw-r--r-- | internal/admin/api_objects.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_room_courses.go | 8 | ||||
| -rw-r--r-- | internal/admin/api_techs.go | 4 | ||||
| -rw-r--r-- | internal/admin/api_triggers.go | 8 | ||||
| -rw-r--r-- | internal/admin/server.go | 3 | ||||
| -rw-r--r-- | internal/admin/yaml_util.go | 6 | ||||
| -rw-r--r-- | internal/game/casino.go | 13 | ||||
| -rw-r--r-- | internal/game/economy.go | 49 | ||||
| -rw-r--r-- | internal/game/economy_recovery_regression_test.go | 118 |
16 files changed, 310 insertions, 20 deletions
diff --git a/internal/admin/api_drops.go b/internal/admin/api_drops.go index 0d14841..3724a6c 100644 --- a/internal/admin/api_drops.go +++ b/internal/admin/api_drops.go @@ -37,6 +37,10 @@ func (s *AdminServer) handleDropByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: @@ -142,6 +146,10 @@ func (s *AdminServer) createDrop(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing drop id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid drop id"}`, http.StatusBadRequest) + return + } path := filepath.Join(s.dataDir, "drops", id+".yaml") if _, err := os.Stat(path); err == nil { diff --git a/internal/admin/api_entity_path_test.go b/internal/admin/api_entity_path_test.go new file mode 100644 index 0000000..7abf65f --- /dev/null +++ b/internal/admin/api_entity_path_test.go @@ -0,0 +1,62 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "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(config.Default(), false, accounts, nil, nil, nil, nil, dir, + nil, nil, nil, nil, filepath.Join(dir, "config.yaml"), + 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()) + } + // 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") + } +} diff --git a/internal/admin/api_hazards.go b/internal/admin/api_hazards.go index 405c9ab..fac68c4 100644 --- a/internal/admin/api_hazards.go +++ b/internal/admin/api_hazards.go @@ -35,6 +35,10 @@ func (s *AdminServer) handleHazardByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: @@ -140,6 +144,10 @@ func (s *AdminServer) createHazard(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing hazard id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid hazard id"}`, http.StatusBadRequest) + return + } path := filepath.Join(s.dataDir, "hazards", id+".yaml") if _, err := os.Stat(path); err == nil { diff --git a/internal/admin/api_helpers.go b/internal/admin/api_helpers.go index 3224598..11ad958 100644 --- a/internal/admin/api_helpers.go +++ b/internal/admin/api_helpers.go @@ -4,10 +4,25 @@ import ( "bytes" "fmt" "os" + "strings" "gopkg.in/yaml.v3" ) +// validEntityID reports whether an entity ID can be safely joined into a +// file path under its data directory. IDs come from URL path segments and +// JSON bodies, so an unchecked join could otherwise escape the data +// directory ("../") or touch dot files. +func validEntityID(id string) bool { + if id == "" || id == "." || id == ".." { + return false + } + if strings.ContainsAny(id, `/\`) || strings.ContainsRune(id, 0) { + return false + } + return true +} + func yamlToMap(data []byte) (map[string]any, error) { var v any if err := yaml.Unmarshal(data, &v); err != nil { diff --git a/internal/admin/api_items.go b/internal/admin/api_items.go index 728997a..749e8ff 100644 --- a/internal/admin/api_items.go +++ b/internal/admin/api_items.go @@ -33,6 +33,10 @@ func (s *AdminServer) handleItemByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: @@ -152,6 +156,10 @@ func (s *AdminServer) createItem(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing item id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid item id"}`, http.StatusBadRequest) + return + } if _, _, err := s.findItemFile(id); err == nil { http.Error(w, `{"error":"item already exists"}`, http.StatusConflict) diff --git a/internal/admin/api_mobs.go b/internal/admin/api_mobs.go index 0646753..8531e40 100644 --- a/internal/admin/api_mobs.go +++ b/internal/admin/api_mobs.go @@ -35,6 +35,10 @@ func (s *AdminServer) handleMobByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: @@ -143,6 +147,10 @@ func (s *AdminServer) createMob(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing mob id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid mob id"}`, http.StatusBadRequest) + return + } if _, _, err := s.findMobFile(id); err == nil { http.Error(w, `{"error":"mob already exists"}`, http.StatusConflict) return diff --git a/internal/admin/api_modules.go b/internal/admin/api_modules.go index e468c79..074eeea 100644 --- a/internal/admin/api_modules.go +++ b/internal/admin/api_modules.go @@ -59,6 +59,10 @@ func (s *AdminServer) handleModuleByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: diff --git a/internal/admin/api_objects.go b/internal/admin/api_objects.go index 340c87c..8f9eac5 100644 --- a/internal/admin/api_objects.go +++ b/internal/admin/api_objects.go @@ -33,6 +33,10 @@ func (s *AdminServer) handleObjectByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: @@ -142,6 +146,10 @@ func (s *AdminServer) createObject(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing object id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid object id"}`, http.StatusBadRequest) + return + } if _, _, err := s.findObjectFile(id); err == nil { http.Error(w, `{"error":"object already exists"}`, http.StatusConflict) diff --git a/internal/admin/api_room_courses.go b/internal/admin/api_room_courses.go index 097b9e8..47e4d8c 100644 --- a/internal/admin/api_room_courses.go +++ b/internal/admin/api_room_courses.go @@ -153,6 +153,10 @@ func (s *AdminServer) handleCourses(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "missing id"}) return } + if !validEntityID(id) { + writeJSON(w, map[string]any{"error": "invalid id"}) + return + } delete(body, "id") path := s.courseFilePath(id) if _, err := os.Stat(path); err == nil { @@ -186,6 +190,10 @@ func (s *AdminServer) handleCourseByID(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "missing id"}) return } + if !validEntityID(id) { + writeJSON(w, map[string]any{"error": "invalid id"}) + return + } path := s.courseFilePath(id) switch r.Method { diff --git a/internal/admin/api_techs.go b/internal/admin/api_techs.go index 293b1db..ba3288a 100644 --- a/internal/admin/api_techs.go +++ b/internal/admin/api_techs.go @@ -67,6 +67,10 @@ func (s *AdminServer) handleTechByID(w http.ResponseWriter, r *http.Request) { http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: diff --git a/internal/admin/api_triggers.go b/internal/admin/api_triggers.go index 749f5a1..87eb82e 100644 --- a/internal/admin/api_triggers.go +++ b/internal/admin/api_triggers.go @@ -40,6 +40,10 @@ func (s *AdminServer) handleTriggers(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"error": "missing id"}) return } + if !validEntityID(id) { + writeJSON(w, map[string]any{"error": "invalid id"}) + return + } delete(m, "id") kind := triggerKind(m) @@ -74,6 +78,10 @@ func (s *AdminServer) handleTriggerByID(w http.ResponseWriter, r *http.Request) http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) return } + if !validEntityID(id) { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } switch r.Method { case http.MethodGet: diff --git a/internal/admin/server.go b/internal/admin/server.go index 3dbe8ad..fb0c53b 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -622,7 +622,8 @@ func (s *AdminServer) handleEntityRename(entityType string) http.HandlerFunc { ID string `json:"id"` NewID string `json:"new_id"` } - if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" { + if err := readJSON(r, &body); err != nil || body.ID == "" || body.NewID == "" || + !validEntityID(body.NewID) { writeJSON(w, map[string]any{"error": "invalid"}) return } diff --git a/internal/admin/yaml_util.go b/internal/admin/yaml_util.go index 7157f15..9ef8058 100644 --- a/internal/admin/yaml_util.go +++ b/internal/admin/yaml_util.go @@ -182,6 +182,9 @@ func findEntityPath(dataDir, subdir, id string) (string, error) { } func moveEntityFile(dataDir, subdir, id, targetDir string) (string, string, []byte, error) { + if targetDir != "" && !validEntityID(targetDir) { + return "", "", nil, fmt.Errorf("invalid directory: %q", targetDir) + } oldPath, err := findEntityPath(dataDir, subdir, id) if err != nil { return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, id) @@ -228,6 +231,9 @@ func createEntityDir(dataDir, subdir, name string) error { } func renameEntityFile(dataDir, subdir, oldID, newID string) (string, string, []byte, error) { + if !validEntityID(newID) { + return "", "", nil, fmt.Errorf("invalid new id: %q", newID) + } oldPath, err := findEntityPath(dataDir, subdir, oldID) if err != nil { return "", "", nil, fmt.Errorf("entity not found: %s/%s", subdir, oldID) diff --git a/internal/game/casino.go b/internal/game/casino.go index 3f06b41..bc1e690 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -82,15 +82,20 @@ func (w casinoWallet) PayCredits(amount int) error { } creditsBefore := w.p.Credits chipsBefore := countChips(w.p) - creditsAfter, err := addCreditsChecked(creditsBefore, amount) - if err != nil { - return err - } + creditsAfter, capErr := addCreditsChecked(creditsBefore, amount) + // The journal record is written FIRST, even when the economy cap + // blocks the payout: a won settlement must never be silently dropped. + // With the cap blocking, the balance is unchanged and the open record + // keeps the owed amount durable; startup recovery re-applies it once + // the balance has room below the cap. rec, err := w.g.Economy.start("payout", w.p, 0, amount, creditsBefore, creditsAfter, chipsBefore, chipsBefore) if err != nil { return casino.ErrWalletPersistence } + if capErr != nil { + return capErr + } w.p.Credits = creditsAfter if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { w.p.Credits = creditsBefore diff --git a/internal/game/economy.go b/internal/game/economy.go index 04508be..afdcdd8 100644 --- a/internal/game/economy.go +++ b/internal/game/economy.go @@ -353,13 +353,21 @@ func (g *Game) recoverEconomy() { players[name] = p return p } - hasLaterPayout := func(name string, seq int) bool { + // laterPayoutEvidence reports whether any non-void payout record was + // journaled for the player after seq — evidence the round resolved and + // its settlement was recorded — together with the total credits those + // records promise. A payout covering the stake's full value returned + // the stake inside the payout (win, push, or refund); a smaller total + // (e.g. the credit side of a crashed mixed refund) leaves the chip + // side of the stake outstanding. + laterPayoutEvidence := func(name string, seq int) (resolved bool, credits int) { for _, fr := range folded { if fr.rec.Kind == "payout" && fr.rec.Player == name && fr.rec.Status != "void" && fr.seq > seq { - return true + resolved = true + credits += fr.rec.Credit } } - return false + return resolved, credits } compensated := 0 @@ -413,13 +421,23 @@ func (g *Game) recoverEconomy() { paid++ compensated++ } - for _, other := range group { - if paid == 0 { - break + for idx, other := range group { + if idx < paid { + g.Economy.settle(other) + settled[other.ID] = true + continue } - g.Economy.settle(other) - settled[other.ID] = true - paid-- + // An unpaid sibling (the save failed, or the credit cap + // blocked re-application) stays owed. Re-anchor its + // checkpoints to the current durable balance so a later + // recovery re-applies it against a balance it can actually + // be paid from — without this, the balance a PAID sibling + // moved reads as this record's own durability and the + // liability is silently settled instead of paid. + other.CreditsBefore = p.Credits + other.CreditsAfter = p.Credits + other.Credit + settled[other.ID] = true // handled for the rest of this run + _ = g.Economy.append(*other) } case p.Credits == rec.CreditsAfter && len(group) == 1: g.Economy.settle(rec) @@ -439,7 +457,7 @@ func (g *Game) recoverEconomy() { } for k := len(openWagers) - 1; k >= 0; k-- { rec := &folded[openWagers[k]].rec - resolvedByPayout := hasLaterPayout(rec.Player, folded[openWagers[k]].seq) + resolvedByPayout, laterPayoutCredits := laterPayoutEvidence(rec.Player, folded[openWagers[k]].seq) p := loadPlayer(rec.Player) if p == nil { continue @@ -449,11 +467,12 @@ func (g *Game) recoverEconomy() { creditsNow, chipsNow := p.Credits, countChips(p) // Chips are recovered independently of the credit checkpoints. Any // state where the chip debit is durable but the chips have not come - // back (post-debit chip balance) still owes the chips, even when a - // later payout record already resolves the credit side. A durable - // chip refund always restores the balance to ChipsBefore, so this - // cannot double-return. - chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter + // back (post-debit chip balance) still owes the chips — unless the + // resolving payout already returned the stake's full value in + // credits (a win, push, or refund includes the stake): returning + // the chips in kind on top would double-refund the round. + chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter && + (!resolvedByPayout || laterPayoutCredits < rec.Chips+rec.Credit) if chipsOwed && !g.giveChips(p, rec.Chips) { // No inventory room for the chip return: keep the record open // for operator inspection rather than silently dropping the diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go index 12df39b..0e764ca 100644 --- a/internal/game/economy_recovery_regression_test.go +++ b/internal/game/economy_recovery_regression_test.go @@ -359,3 +359,121 @@ func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) { t.Fatalf("nothing may stay owed after recovery: %+v", open) } } + +// TestRecoveryDoesNotReturnPaidChipStakeInKind: a chip wager whose round +// resolved with a durable payout (a win returns the stake inside the +// payout) must not ALSO get the chips back in kind on recovery — the +// resolving payout is the refund. Only a payout that does not cover the +// stake's full value (e.g. the credit side of a crashed mixed refund) +// leaves the chip side outstanding. +func TestRecoveryDoesNotReturnPaidChipStakeInKind(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + // The round resolved: a 2x payout (20) is durable, then the crash + // lands before the stake's resolution marker. + if err := w.PayCredits(20); err != nil { + t.Error(err) + return + } + h.g.recoverEconomy() + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 120 || countChips(loaded) != 0 { + t.Fatalf("won chip bet after recovery = (%d credits, %d chips), want (120, 0) — no double refund", + loaded.Credits, countChips(loaded)) + } +} + +// TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries: an unpaid payout +// liability (the credit cap blocked re-application) must survive repeated +// recoveries. Without checkpoint re-anchoring, a later recovery mistakes +// the balance a paid sibling moved for the unpaid record's own durability +// and silently erases the owed amount. +func TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = casino.MaxCredits - 30 + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Error(err) + return + } + for i := 0; i < 2; i++ { + if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil { + t.Error(err) + return + } + } + h.g.recoverEconomy() + if len(h.g.Economy.loadOpen()) != 1 { + t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.loadOpen())) + return + } + // A later startup over the same files: the liability stays owed. + h.g.recoverEconomy() + if got := len(h.g.Economy.loadOpen()); got != 1 { + t.Errorf("second recovery erased the unpaid 20-credit liability: open=%d, want 1", got) + } + }) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != casino.MaxCredits-10 { + t.Fatalf("balance after repeated recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10) + } +} + +// TestCapBlockedPayoutIsJournaled: a won payout at the economy cap must be +// recorded as an owed (open) payout record even though it cannot be +// applied — a reported win is never silently dropped. +func TestCapBlockedPayoutIsJournaled(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice", "pw", false) + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = casino.MaxCredits + w := casinoWallet{g: h.g, p: p} + if _, err := w.Wager(10); err != nil { + t.Error(err) + return + } + if err := w.PayCredits(20); err == nil { + t.Error("payout at the credit cap must fail, not silently clamp") + return + } + if p.Credits != casino.MaxCredits-10 { + t.Errorf("cap-blocked payout mutated the balance: %d", p.Credits) + } + }) + if err := h.g.AccountStore.SaveCharacter(p); err != nil { + t.Fatal(err) + } + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + // Recovery: the owed win cannot be applied (cap), so the liability + // stays open for operator inspection, never erased; the consumed + // stake is settled against the payout evidence. + if got := len(restarted.g.Economy.loadOpen()); got != 1 { + t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.loadOpen()) + } + if loaded.Credits != casino.MaxCredits-10 { + t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits) + } +} |
