diff options
| author | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 17:48:26 -0400 |
| commit | 8fe9e3c3fef0049df82281a62afe35dda516dfe2 (patch) | |
| tree | dd269f2c0710f334e19f00a191260ebc46284a90 /internal/game/login_throttle.go | |
| parent | 194164a77eb4f2479b94c19e6f0e1278ab608781 (diff) | |
| download | thehouseoficarus-8fe9e3c3fef0049df82281a62afe35dda516dfe2.tar.gz | |
fix: close combat ghost, admin traversal, login throttle, journal double-pay, and quality/economy gaps
Diffstat (limited to 'internal/game/login_throttle.go')
| -rw-r--r-- | internal/game/login_throttle.go | 114 |
1 files changed, 114 insertions, 0 deletions
diff --git a/internal/game/login_throttle.go b/internal/game/login_throttle.go new file mode 100644 index 0000000..b819188 --- /dev/null +++ b/internal/game/login_throttle.go @@ -0,0 +1,114 @@ +package game + +import ( + "sync" + "time" +) + +// Interactive login throttling. The admin HTTP /login route has its own +// limiter; the telnet/TLS/websocket login is the primary credential surface +// and needs one too. The policy is an escalating delay keyed on +// (account, client IP): a hard lockout is deliberately avoided because +// account existence is discoverable and a lockout would be a targeted +// griefing tool. The delay runs on the connection goroutine, never the +// owner, so a brute-force attempt stalls only its own connection. + +const ( + // loginThrottleMaxDelay caps the escalation so a legitimate player who + // fat-fingers a password a few times is inconvenienced, not locked out. + loginThrottleMaxDelay = 4 * time.Second + // loginThrottleMaxKeys bounds the tracked keys so a source that cycles + // account names or addresses cannot grow server memory without bound. + loginThrottleMaxKeys = 4096 + // loginThrottleEntryTTL is how long an untouched entry is retained. + loginThrottleEntryTTL = 10 * time.Minute +) + +type loginThrottleEntry struct { + failures int + lastSeen time.Time +} + +type loginThrottle struct { + mu sync.Mutex + entries map[string]loginThrottleEntry +} + +func newLoginThrottle() *loginThrottle { + return &loginThrottle{entries: make(map[string]loginThrottleEntry)} +} + +// ensureLoginThrottle returns the game's login throttle, creating it on first +// use. The connection goroutine calls this; sync.Once makes the lazy init +// race-free for Game literals that did not go through New. +func (g *Game) ensureLoginThrottle() *loginThrottle { + g.loginThrottleOnce.Do(func() { + g.loginThrottle = newLoginThrottle() + }) + return g.loginThrottle +} + +// loginThrottleKey combines the attempted account and the client address. +// Either may be empty (synthetic connections). +func loginThrottleKey(account, ip string) string { + return account + "\x00" + ip +} + +// delayFor records a failed attempt and returns the escalating delay the +// caller should wait before responding: 0, 250ms, 500ms, 1s, 2s, 4s, 4s... +func (t *loginThrottle) delayFor(key string) time.Duration { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + if len(t.entries) >= loginThrottleMaxKeys { + t.evictLocked(now) + } + e := t.entries[key] + e.failures++ + e.lastSeen = now + t.entries[key] = e + if e.failures < 2 { + // The first wrong password is free: a single typo must not cost a + // legitimate player any time. + return 0 + } + shift := e.failures - 2 + if shift > 4 { // 250ms << 4 == loginThrottleMaxDelay + shift = 4 + } + d := time.Duration(250*int64(1<<uint(shift))) * time.Millisecond + if d > loginThrottleMaxDelay { + d = loginThrottleMaxDelay + } + return d +} + +// clear forgets a key after a successful login. +func (t *loginThrottle) clear(key string) { + t.mu.Lock() + delete(t.entries, key) + t.mu.Unlock() +} + +// evictLocked drops expired entries, then the oldest entry if still at the +// cap. Caller holds mu. +func (t *loginThrottle) evictLocked(now time.Time) { + for k, e := range t.entries { + if now.Sub(e.lastSeen) > loginThrottleEntryTTL { + delete(t.entries, k) + } + } + if len(t.entries) < loginThrottleMaxKeys { + return + } + oldestKey := "" + var oldest time.Time + for k, e := range t.entries { + if oldestKey == "" || e.lastSeen.Before(oldest) { + oldestKey, oldest = k, e.lastSeen + } + } + if oldestKey != "" { + delete(t.entries, oldestKey) + } +} |
