Harden UI: login brute-force throttle, security headers, Secure cookie flag
- 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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1539c589a1
commit
4d171b4ff7
@@ -0,0 +1,98 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user