From 8fe9e3c3fef0049df82281a62afe35dda516dfe2 Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 17:48:26 -0400 Subject: fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps --- internal/admin/server.go | 79 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 6 deletions(-) (limited to 'internal/admin/server.go') diff --git a/internal/admin/server.go b/internal/admin/server.go index 98400c2..05586ef 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -473,6 +473,7 @@ type loginLimiter struct { type loginAttempts struct { failures int + inflight int blockedTill time.Time lastSeen time.Time } @@ -481,6 +482,66 @@ func newLoginLimiter() *loginLimiter { return &loginLimiter{entries: make(map[string]loginAttempts), now: time.Now} } +// begin atomically checks the cooldown and reserves an in-flight slot for +// ip. Reserving at admission time closes the TOCTOU where a concurrent burst +// all passes allow() before any of them records a failure: the number of +// simultaneous verifications for one address is bounded by the same +// threshold. Every successful begin must be paired with finish. +func (l *loginLimiter) begin(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + e, ok := l.entries[ip] + if !ok { + if !l.makeRoomLocked(now) { + // The tracked map is full of cooling-down addresses; do not + // reserve (preserves the existing bounded-map behavior). + return true + } + e = loginAttempts{} + } + if now.Before(e.blockedTill) { + return false + } + if e.failures+e.inflight >= adminLoginMaxFailures { + return false + } + e.inflight++ + e.lastSeen = now + l.entries[ip] = e + return true +} + +// finish releases the in-flight slot reserved by begin and records the +// outcome: success clears the address, failure advances the cooldown. +func (l *loginLimiter) finish(ip string, ok bool) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + e, tracked := l.entries[ip] + if !tracked { + return + } + if e.inflight > 0 { + e.inflight-- + } + e.lastSeen = now + if ok { + delete(l.entries, ip) + return + } + if now.Before(e.blockedTill) { + l.entries[ip] = e + return + } + e.failures++ + if e.failures >= adminLoginMaxFailures { + e.blockedTill = now.Add(adminLoginCooldown) + e.failures = 0 + } + l.entries[ip] = e +} + // allow reports whether ip may attempt a login now. func (l *loginLimiter) allow(ip string) bool { l.mu.Lock() @@ -540,7 +601,7 @@ func (l *loginLimiter) makeRoomLocked(now time.Time) bool { return true } for ip, e := range l.entries { - if e.failures == 0 && !now.Before(e.blockedTill) { + if e.failures == 0 && e.inflight == 0 && !now.Before(e.blockedTill) { delete(l.entries, ip) } } @@ -616,10 +677,12 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { // to make password guessing expensive, so it must not cost a parse. limiter := s.limitLogins() ip := clientIP(r.RemoteAddr) - if !limiter.allow(ip) { + if !limiter.begin(ip) { http.Error(w, "too many failed login attempts; try again later", http.StatusTooManyRequests) return } + loginOK := false + defer func() { limiter.finish(ip, loginOK) }() // /login is registered before authMiddleware, so bound and buffer its body // here before r.FormValue can parse it: without the cap a multipart POST @@ -646,20 +709,17 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { // 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) s.renderPage(w, r, "login.html", map[string]any{"Error": "Invalid account name or password."}) return } if !acc.Admin { - limiter.fail(ip) s.renderPage(w, r, "login.html", map[string]any{"Error": "Account is not authorized as admin."}) return } @@ -693,7 +753,7 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) { return } s.setAuthCookie(w, account) - limiter.succeed(ip) + loginOK = true http.Redirect(w, r, "/", http.StatusFound) } @@ -898,6 +958,13 @@ func (s *AdminServer) handleEntityDirs(entityType string) http.HandlerFunc { failJSON(w, err.Error()) return } + // Undo history stores absolute paths; re-prefix them so a later + // undo does not recreate the old entity directory. + if s.undoStack != nil { + oldPrefix := filepath.Join(s.dataDir, entityType, body.Dir) + newPrefix := filepath.Join(s.dataDir, entityType, body.NewName) + s.undoStack.RewritePaths(oldPrefix, newPrefix) + } writeJSON(w, map[string]any{"ok": true, "name": body.NewName}) default: failJSON(w, "unknown action") -- cgit v1.2.3