- New in-memory login throttle (handler/login_throttle.go): 5 failed attempts per IP or per username within 5 minutes locks that key out for 5 minutes, applied to both /login and the TOTP verification step, which previously had no rate limiting at all - router.New now adds middleware.Secure with X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and HSTS (only when cookies are Secure, implying an HTTPS deployment). No CSP: the existing templates rely on inline <script> blocks, so a CSP strict enough to matter would need 'unsafe-inline' anyway - All session/auth cookies now set Secure based on the new --cookie-secure flag / WGUI_COOKIE_SECURE env var (default true) so the session cookie is never sent over plain HTTP unless explicitly opted into an HTTP-only LAN deployment Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATVUwTa4Pqwq26orW5BcDW
99 lines
2.6 KiB
Go
99 lines
2.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// loginThrottle tracks failed login attempts per key (username and/or
|
|
// client IP) in memory and locks a key out for a fixed window once it
|
|
// crosses maxFailedAttempts. There is no persistent brute-force
|
|
// protection anywhere else in the login path (see Login in routes.go),
|
|
// so an attacker could otherwise try passwords as fast as the network
|
|
// allows.
|
|
type loginThrottle struct {
|
|
mu sync.Mutex
|
|
attempts map[string]*throttleEntry
|
|
}
|
|
|
|
type throttleEntry struct {
|
|
failures int
|
|
lockedUntil time.Time
|
|
windowStart time.Time
|
|
}
|
|
|
|
const (
|
|
maxFailedAttempts = 5
|
|
failureWindow = 5 * time.Minute
|
|
lockoutDuration = 5 * time.Minute
|
|
throttleSweepEvery = 10 * time.Minute
|
|
)
|
|
|
|
var globalLoginThrottle = &loginThrottle{attempts: make(map[string]*throttleEntry)}
|
|
|
|
func init() {
|
|
go globalLoginThrottle.sweepLoop()
|
|
}
|
|
|
|
// sweepLoop periodically drops stale entries so the map doesn't grow
|
|
// unbounded on a long-running instance under sustained scanning.
|
|
func (t *loginThrottle) sweepLoop() {
|
|
for {
|
|
time.Sleep(throttleSweepEvery)
|
|
now := time.Now()
|
|
t.mu.Lock()
|
|
for k, e := range t.attempts {
|
|
if now.After(e.lockedUntil) && now.Sub(e.windowStart) > failureWindow {
|
|
delete(t.attempts, k)
|
|
}
|
|
}
|
|
t.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// allow reports whether key (typically remote IP, optionally combined
|
|
// with the attempted username) is currently allowed to attempt a login.
|
|
func (t *loginThrottle) allow(key string) bool {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
e, ok := t.attempts[key]
|
|
if !ok {
|
|
return true
|
|
}
|
|
return time.Now().After(e.lockedUntil)
|
|
}
|
|
|
|
// recordFailure registers a failed attempt for key, locking it out once
|
|
// maxFailedAttempts is reached within failureWindow.
|
|
func (t *loginThrottle) recordFailure(key string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
now := time.Now()
|
|
e, ok := t.attempts[key]
|
|
if !ok || now.Sub(e.windowStart) > failureWindow {
|
|
e = &throttleEntry{windowStart: now}
|
|
t.attempts[key] = e
|
|
}
|
|
e.failures++
|
|
if e.failures >= maxFailedAttempts {
|
|
e.lockedUntil = now.Add(lockoutDuration)
|
|
}
|
|
}
|
|
|
|
// recordSuccess clears any throttle state for key on a successful login,
|
|
// so a legitimate user isn't penalized by earlier typos once they get it
|
|
// right.
|
|
func (t *loginThrottle) recordSuccess(key string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
delete(t.attempts, key)
|
|
}
|
|
|
|
// tooManyRequests writes a 429 response for a throttled login attempt.
|
|
func tooManyRequests(w http.ResponseWriter) error {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, err := w.Write([]byte(`{"success":false,"message":"Too many failed login attempts. Please wait a few minutes and try again."}`))
|
|
return err
|
|
}
|