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.go220
1 files changed, 219 insertions, 1 deletions
diff --git a/internal/admin/server.go b/internal/admin/server.go
index d9a0386..4a54c78 100644
--- a/internal/admin/server.go
+++ b/internal/admin/server.go
@@ -4,6 +4,7 @@
package admin
import (
+ "bytes"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
@@ -14,6 +15,7 @@ import (
"embed"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"html/template"
"io"
@@ -26,6 +28,7 @@ import (
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"thehouseoficarus/internal/behavior"
@@ -74,6 +77,18 @@ type AdminServer struct {
// change (create/update/delete/rename/move/undo/redo). Wired from the
// Game so the web editor's item edits stay consistent with gameplay.
rebuildCraftIndex func()
+
+ // playerFileOnlineCheck reports whether the character/account addressed
+ // by a data-relative player file path is currently online. Wired from the
+ // game (SetPlayerFileOnlineCheck) so the raw file editor refuses to
+ // overwrite a live character/account's file. Optional; nil disables it.
+ playerFileOnlineCheck func(relPath string) bool
+
+ // loginLimiterOnce/loginLimiter hold the bounded per-IP failed-login
+ // throttle for /login. The limiter is created on first use so an
+ // AdminServer built directly (tests) behaves like one from NewServer.
+ loginLimiterOnce sync.Once
+ loginLimiter *loginLimiter
}
func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStore, w *world.World, is *item.ItemStore, os *object.ObjectStore, ms *world.MobStore, dataDir string, reloadCourses func(), rewriteRoomIDs func(map[int]int), evictRoom func(roomID int), rebuildCraftIndex func(), configPath string, runOnOwner func(func()) bool) (*AdminServer, error) {
@@ -139,7 +154,23 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
if err != nil {
return nil, fmt.Errorf("static subfs: %w", err)
}
- mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
+ // Serve embedded assets by exact name: this both requires auth (except
+ // for the stylesheet the unauthenticated login page needs) and never
+ // emits the http.FileServer directory listing that leaked the UI's
+ // module names.
+ mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, "/static/")
+ if name != "admin.css" && !s.checkAuth(r) {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ data, err := fs.ReadFile(staticFS, name)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
+ })
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout)
@@ -292,6 +323,21 @@ func NewServer(cfg *config.Config, useTLS bool, accountStore *player.AccountStor
return s, nil
}
+// SetPlayerFileOnlineCheck wires the online-session check used by the raw
+// file editor to refuse editing/deleting/renaming a character or account file
+// while that character/account is online. It is optional: when unset the
+// editor behaves as before. main.go wires it to a game-side lookup.
+//
+// Undo/redo writes the same files through the undo stack, so the hook is
+// handed to it as well: undoing an edit to a live character's file must be
+// refused like the edit itself.
+func (s *AdminServer) SetPlayerFileOnlineCheck(fn func(relPath string) bool) {
+ s.playerFileOnlineCheck = fn
+ if s.undoStack != nil {
+ s.undoStack.SetPlayerFileOnlineCheck(fn)
+ }
+}
+
// ServeHTTP exposes the same authenticated routes used by ListenAndServe.
func (s *AdminServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.httpServer.Handler.ServeHTTP(w, r)
@@ -362,6 +408,149 @@ func (s *AdminServer) isAdminAccount(name string) bool {
// indefinitely and the server can age it out without server-side state.
const adminSessionTTL = 24 * time.Hour
+// maxLoginBodyBytes bounds the unauthenticated /login request body before any
+// form or multipart parsing. 64 KiB is far above any real login form.
+const maxLoginBodyBytes = 64 << 10
+
+// Failed-login throttling for the unauthenticated /login route. The telnet
+// login path is deliberately NOT covered: net.Conn/Session expose no client
+// identity, so there is nothing to key a limit on.
+const (
+ // adminLoginMaxFailures is the number of failed attempts an IP may make
+ // before it is refused.
+ adminLoginMaxFailures = 5
+ // adminLoginCooldown is how long a refused IP stays refused.
+ adminLoginCooldown = 5 * time.Minute
+ // adminLoginMaxTracked caps the tracked client IPs, so a source that
+ // cycles addresses cannot grow server memory without bound.
+ adminLoginMaxTracked = 1024
+)
+
+// loginLimiter is a small, bounded, in-memory failed-login limiter keyed by
+// client IP. After adminLoginMaxFailures consecutive failures an IP is
+// refused for adminLoginCooldown; a successful login clears its state. The
+// entry map holds at most adminLoginMaxTracked keys (the least recently seen
+// entry without an active cooldown is evicted first), and every method is
+// safe for concurrent use: /login is unauthenticated and served by many
+// goroutines at once.
+//
+// now is the limiter's clock, read under mu on every call. Tests replace it
+// once, before the server serves a request, to drive the cooldown
+// deterministically; production leaves it at time.Now.
+type loginLimiter struct {
+ mu sync.Mutex
+ entries map[string]loginAttempts
+ now func() time.Time
+}
+
+type loginAttempts struct {
+ failures int
+ blockedTill time.Time
+ lastSeen time.Time
+}
+
+func newLoginLimiter() *loginLimiter {
+ return &loginLimiter{entries: make(map[string]loginAttempts), now: time.Now}
+}
+
+// allow reports whether ip may attempt a login now.
+func (l *loginLimiter) allow(ip string) bool {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ e, ok := l.entries[ip]
+ if !ok {
+ return true
+ }
+ return !l.now().Before(e.blockedTill)
+}
+
+// fail records a failed attempt for ip and starts the cooldown once the
+// threshold is reached. A still-blocked IP keeps its existing cooldown: a
+// blocked source cannot extend its own block by hammering the route, and an
+// address that cannot be tracked while every tracked address is cooling down
+// is left untracked rather than evicting a live cooldown.
+func (l *loginLimiter) fail(ip string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ now := l.now()
+ e, ok := l.entries[ip]
+ if !ok && !l.makeRoomLocked(now) {
+ return
+ }
+ e.lastSeen = now
+ 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
+}
+
+// succeed clears ip's failure state after a successful login.
+func (l *loginLimiter) succeed(ip string) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ delete(l.entries, ip)
+}
+
+// makeRoomLocked makes room for one new tracked address once the map is at
+// its cap, reporting false when it cannot. Fully expired entries (no cooldown
+// left and no failures counted) go first; if the map is still full the least
+// recently seen entry without an active cooldown is dropped.
+//
+// An entry with an active cooldown is NEVER evicted: a blocked source must not
+// be able to unblock itself by filling the map with other addresses, which is
+// exactly what dropping its (oldest) entry would hand it. When every tracked
+// address is still cooling down the map stays bounded and the new address is
+// simply not tracked.
+func (l *loginLimiter) makeRoomLocked(now time.Time) bool {
+ if len(l.entries) < adminLoginMaxTracked {
+ return true
+ }
+ for ip, e := range l.entries {
+ if e.failures == 0 && !now.Before(e.blockedTill) {
+ delete(l.entries, ip)
+ }
+ }
+ for len(l.entries) >= adminLoginMaxTracked {
+ oldest, found := "", false
+ for ip, e := range l.entries {
+ if now.Before(e.blockedTill) {
+ continue
+ }
+ if !found || e.lastSeen.Before(l.entries[oldest].lastSeen) {
+ oldest, found = ip, true
+ }
+ }
+ if !found {
+ return false
+ }
+ delete(l.entries, oldest)
+ }
+ return true
+}
+
+// limitLogins returns the server's login limiter, creating it on first use.
+func (s *AdminServer) limitLogins() *loginLimiter {
+ s.loginLimiterOnce.Do(func() { s.loginLimiter = newLoginLimiter() })
+ return s.loginLimiter
+}
+
+// clientIP keys the login limiter by the request's client address. RemoteAddr
+// is "host:port" for TCP transports; anything without a port (synthetic
+// requests, unix sockets) is used verbatim. Forwarded headers are ignored:
+// they are client-controlled and would let one source pose as many.
+func clientIP(remoteAddr string) string {
+ if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
+ return host
+ }
+ return remoteAddr
+}
+
func (s *AdminServer) setAuthCookie(w http.ResponseWriter, account string) {
issued := strconv.FormatInt(time.Now().Unix(), 10)
mac := hmac.New(sha256.New, s.cookieSecret)
@@ -389,16 +578,44 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
+ // Refuse a cooling-down source before reading its body: the limit exists
+ // to make password guessing expensive, so it must not cost a parse.
+ limiter := s.limitLogins()
+ ip := clientIP(r.RemoteAddr)
+ if !limiter.allow(ip) {
+ http.Error(w, "too many failed login attempts; try again later", http.StatusTooManyRequests)
+ return
+ }
+
+ // /login is registered before authMiddleware, so bound and buffer its body
+ // here before r.FormValue can parse it: without the cap a multipart POST
+ // spills unbounded data to temp files (ParseMultipartForm's own 32 MiB cap
+ // only bounds memory).
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxLoginBodyBytes))
+ r.Body.Close()
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ http.Error(w, "cannot read request body", http.StatusBadRequest)
+ return
+ }
+ r.Body = io.NopCloser(bytes.NewReader(body))
+
account := strings.TrimSpace(r.FormValue("account"))
password := r.FormValue("password")
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
}
@@ -432,6 +649,7 @@ func (s *AdminServer) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.setAuthCookie(w, account)
+ limiter.succeed(ip)
http.Redirect(w, r, "/", http.StatusFound)
}