aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/server.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/server.go')
-rw-r--r--internal/admin/server.go79
1 files changed, 73 insertions, 6 deletions
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")