From 7eab582d8a8ee2af1a6baae7baf0696c7005de2e Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 15:16:44 -0400 Subject: fix: harden triggers, economy, casino, world data, and admin/net paths --- internal/admin/api_files.go | 16 +++++++++++++++ internal/admin/api_map.go | 10 +++++++++ internal/admin/server.go | 50 +++++++++++++++++++++++++++++++++++++++++++-- internal/admin/undo.go | 8 +++++--- 4 files changed, 79 insertions(+), 5 deletions(-) (limited to 'internal/admin') diff --git a/internal/admin/api_files.go b/internal/admin/api_files.go index 96b5e81..2aa0e5f 100644 --- a/internal/admin/api_files.go +++ b/internal/admin/api_files.go @@ -108,6 +108,22 @@ func (s *AdminServer) handleFileRename(w http.ResponseWriter, r *http.Request) { return } + // A raw rename changes the ID-bearing filename. Rebuild the room index + // and drop the old room's cached state (mirroring the DELETE path), and + // refresh item/mob/drop caches, or the old ID resolves to a deleted file + // and the new one resolves to nothing until a restart. + s.evictDeletedRoomFile(relPath) + s.invalidateEntityCacheForPath(newAbs) + // Hazards and courses have their own runtime caches that a raw rename + // must refresh (the undo path does the same). Techs and triggers keep the + // documented in-game `reload` requirement. + if pathUnderDir(s.dataDir, "hazards", newAbs) { + s.world.ClearHazardCache() + } + if pathUnderDir(s.dataDir, "courses", newAbs) { + s.reloadCoursesIfWired() + } + newRelPath := filepath.Join(filepath.Dir(relPath), newName) if filepath.Dir(relPath) == "." { newRelPath = newName diff --git a/internal/admin/api_map.go b/internal/admin/api_map.go index 5eac385..d0372df 100644 --- a/internal/admin/api_map.go +++ b/internal/admin/api_map.go @@ -24,6 +24,16 @@ func (s *AdminServer) handleMap(w http.ResponseWriter, r *http.Request) { } dir := r.URL.Query().Get("dir") + if dir != "" { + // Route the map's directory through the same guard every other room + // API uses, so ?dir=../../.. cannot read/walk outside data/rooms. + clean, err := s.resolveRoomSubdir(dir) + if err != nil { + failJSON(w, err.Error()) + return + } + dir = clean + } seed := s.cfg.StartingRoom if dir != "" { if low, ok := findLowestLoadableRoom(s, dir); ok { diff --git a/internal/admin/server.go b/internal/admin/server.go index 4a54c78..4ea22d8 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -365,6 +365,20 @@ func (s *AdminServer) authMiddleware(next http.Handler) http.Handler { }) } +// UndoStack returns the server's undo history. Two admin transports over the +// same data directory must share one stack: each server otherwise loads and +// rewrites the same history file independently, so records made through one +// are silently dropped by the next write from the other. +func (s *AdminServer) UndoStack() *UndoStack { return s.undoStack } + +// SetUndoStack replaces the server's undo history with a shared stack. Call +// before serving requests. +func (s *AdminServer) SetUndoStack(us *UndoStack) { + if us != nil { + s.undoStack = us + } +} + func (s *AdminServer) checkAuth(r *http.Request) bool { cookie, err := r.Cookie("admin_session") if err != nil { @@ -389,8 +403,16 @@ func (s *AdminServer) checkAuth(r *http.Request) bool { return false } + // Bind the signature to the current password hash as well as the issue + // time, so changing an admin password invalidates every previously issued + // cookie instead of leaving it valid for the full TTL. The admin check + // above already loaded the account; reload it here for the hash. + acc, err := s.accountStore.LoadAccount(account) + if err != nil || !acc.Admin { + return false + } mac := hmac.New(sha256.New, s.cookieSecret) - mac.Write([]byte(account + ":" + parts[1])) + mac.Write([]byte(account + ":" + parts[1] + ":" + acc.PasswordHash)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected)) } @@ -552,9 +574,15 @@ func clientIP(remoteAddr string) string { } func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) { + // Include the current password hash in the MAC so a password change + // revokes outstanding cookies (see checkAuth). + hash := "" + if acc, err := s.accountStore.LoadAccount(account); err == nil { + hash = acc.PasswordHash + } issued := strconv.FormatInt(time.Now().Unix(), 10) mac := hmac.New(sha256.New, s.cookieSecret) - mac.Write([]byte(account + ":" + issued)) + mac.Write([]byte(account + ":" + issued + ":" + hash)) sig := hex.EncodeToString(mac.Sum(nil)) http.SetCookie(w, &http.Cookie{ Name: "admin_session", @@ -607,6 +635,16 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { account := strings.TrimSpace(r.FormValue("account")) password := r.FormValue("password") + // Validate before the name is joined into a filesystem path: LoadAccount + // does no validation, so an unvalidated name is an arbitrary-*.yaml read + // primitive ("account=../../rooms/1"). Every other account-name entry + // point validates first. + if err := player.ValidName(account); err != nil { + limiter.fail(ip) + s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) + return + } + acc, err := s.accountStore.LoadAccount(account) if err != nil || !player.CheckPassword(password, acc.PasswordHash) { limiter.fail(ip) @@ -1004,6 +1042,14 @@ func (s *AdminServer) invalidateEntityCacheForPath(path string) { } } +// pathUnderDir reports whether path lies inside dataDir/subdir (and is not the +// directory itself). Used by the raw-rename path to refresh caches that the +// shared invalidateEntityCacheForPath does not own. +func pathUnderDir(dataDir, subdir, path string) bool { + rel, err := filepath.Rel(filepath.Join(dataDir, subdir), path) + return err == nil && rel != "." && !strings.HasPrefix(rel, "..") +} + func changeFiles(change *ChangeDesc) []string { files := []string{change.FilePath} if change.NewFilePath != "" { diff --git a/internal/admin/undo.go b/internal/admin/undo.go index 7bfbe09..24ce9c1 100644 --- a/internal/admin/undo.go +++ b/internal/admin/undo.go @@ -9,6 +9,8 @@ import ( "strings" "sync" "time" + + "thehouseoficarus/internal/persistence" ) type ExtraFile struct { @@ -537,12 +539,12 @@ func (us *UndoStack) save() { log.Printf("undo: failed to marshal history: %v", err) return } - if err := os.WriteFile(us.filePath, b, 0600); err != nil { + if err := persistence.WriteFile(us.filePath, b, 0600); err != nil { log.Printf("undo: failed to write history file: %v", err) return } - // os.WriteFile does not change an existing file's mode: tighten a history - // file that a previous version created world-readable. + // persistence.WriteFile preserves an existing file's mode: tighten a + // history file that a previous version created world-readable. if err := os.Chmod(us.filePath, 0600); err != nil { log.Printf("undo: failed to secure history file: %v", err) } -- cgit v1.2.3