diff options
Diffstat (limited to 'internal/admin/server.go')
| -rw-r--r-- | internal/admin/server.go | 220 |
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) } |
