From 153acb7f644d755c45533b7c5311e3ffa47e8973 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 05:31:09 -0400 Subject: fix: harden engine across casino, economy, concurrency, security, and content --- internal/game/account_txn.go | 90 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 81 insertions(+), 9 deletions(-) (limited to 'internal/game/account_txn.go') diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go index cf31dea..13f7695 100644 --- a/internal/game/account_txn.go +++ b/internal/game/account_txn.go @@ -121,9 +121,36 @@ func (g *Game) canonicalChar(name string) (string, error) { return g.AccountStore.FindCharacter(name) } +// validateAccountName and validateCharacterName reject names that any +// AccountPath/CharPath join would interpret as a path (separators, dots) or +// that no account/character in this store may carry. Every lifecycle +// transaction validates its names BEFORE it touches the filesystem: a +// caller-supplied or account-file-supplied name such as "../../x" would +// otherwise make AccountPath/CharPath read or delete files outside +// players/, and a transaction is not the place to discover that. +func validateAccountName(name string) error { + if err := player.ValidName(name); err != nil { + return fmt.Errorf("invalid account name %q: %w", name, err) + } + return nil +} + +func validateCharacterName(name string) error { + if err := player.ValidName(name); err != nil { + return fmt.Errorf("invalid character name %q: %w", name, err) + } + return nil +} + // createCharacterTxn creates the character file and appends it to the // owning account in a single transaction. Returns the created player. func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int) (*player.Player, error) { + if err := validateAccountName(accountName); err != nil { + return nil, err + } + if err := validateCharacterName(charName); err != nil { + return nil, err + } p := player.New(charName) p.RoomID = startingRoom p.Stats.RecordRoomVisit(startingRoom) @@ -155,6 +182,15 @@ func (g *Game) createCharacterTxn(accountName, charName string, startingRoom int // renameCharacterTxn atomically renames the character file, persists the new // name inside it, and updates the owning account's character list. func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error { + if err := validateAccountName(accountName); err != nil { + return err + } + if err := validateCharacterName(oldName); err != nil { + return err + } + if err := validateCharacterName(newName); err != nil { + return err + } txn := g.newTxn() if err := txn.Move(g.AccountStore.CharPath(oldName), g.AccountStore.CharPath(newName)); err != nil { return err @@ -202,6 +238,12 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error { // deleteCharacterTxn removes the character file and updates the account list // atomically. func (g *Game) deleteCharacterTxn(accountName, charName string) error { + if err := validateAccountName(accountName); err != nil { + return err + } + if err := validateCharacterName(charName); err != nil { + return err + } txn := g.newTxn() if err := txn.Delete(g.AccountStore.CharPath(charName)); err != nil { return err @@ -228,8 +270,19 @@ func (g *Game) deleteCharacterTxn(accountName, charName string) error { } // purgeAccountTxn removes the account file and every character file in one -// transaction. +// transaction. The account's character list arrives from the account file, +// so every name in it is validated before the first CharPath join: a +// hand-edited or corrupt entry must not turn a purge into a delete outside +// players/characters/. func (g *Game) purgeAccountTxn(accountName string, characters []string) error { + if err := validateAccountName(accountName); err != nil { + return err + } + for _, name := range characters { + if err := validateCharacterName(name); err != nil { + return err + } + } txn := g.newTxn() for _, name := range characters { if err := txn.Delete(g.AccountStore.CharPath(name)); err != nil { @@ -247,15 +300,30 @@ func (g *Game) purgeAccountTxn(accountName string, characters []string) error { // transaction. Without the character rewrite the renamed account loses // access to its characters: membership authorization compares the // character file's owner against the account name. +// +// Both names and every entry of the account's character list are validated +// up front: each one is joined into AccountPath/CharPath below, and a +// path-like name would escape players/. func (g *Game) renameAccountTxn(oldName, newName string) error { - txn := g.newTxn() - if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil { + if err := validateAccountName(oldName); err != nil { + return err + } + if err := validateAccountName(newName); err != nil { return err } acc, err := g.AccountStore.LoadAccount(oldName) if err != nil { return err } + for _, charName := range acc.Characters { + if err := validateCharacterName(charName); err != nil { + return err + } + } + txn := g.newTxn() + if err := txn.Move(g.AccountStore.AccountPath(oldName), g.AccountStore.AccountPath(newName)); err != nil { + return err + } acc.Name = newName accData, err := g.AccountStore.MarshalAccount(acc) if err != nil { @@ -311,7 +379,9 @@ func (g *Game) isMemberOf(accountName, characterName string) bool { // publishAccountUpdate reloads the account from disk and republishes its // state to every session of that account, resetting stale pending menu // selections (pending character names, menu choices) after lifecycle -// mutations. Called from the owner goroutine only. +// mutations. A session whose account no longer exists on disk is reset to the +// account-name prompt (resetToAccountName) and closed. Called from the owner +// goroutine only. // // oldName and newName differ only for an account rename: sessions // authenticated before the rename still carry the OLD cached account @@ -321,12 +391,14 @@ func (g *Game) publishAccountUpdate(oldName, newName string) { for _, sess := range g.allSessionsOfEitherAccount(oldName, newName) { acc, err := g.AccountStore.LoadAccount(newName) if err != nil { - // The account file vanished (purge from another session); the - // session is stale and must leave the menu. - sess.Account = nil - sess.PendingChar = "" - sess.PendingMenu = nil + // The account file vanished (purge or a raw admin delete from + // another session); the session is stale and must leave the menu. + // resetToAccountName clears PendingPass and the pending state + // alongside Account: a stale confirm-password still matching + // PendingPass would otherwise reach a handler that expects an + // account bound to the session. sess.WriteLine("\nYour account no longer exists.") + g.resetToAccountName(sess) sess.Close() continue } -- cgit v1.2.3