Files
wireguard-ui-multi/handler/login_throttle.go
T
sysopsandClaude Sonnet 5 4d171b4ff7 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
2026-07-29 15:03:21 +02:00

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
}