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:
sysops
2026-07-29 15:03:21 +02:00
co-authored by Claude Sonnet 5
parent 1539c589a1
commit 4d171b4ff7
7 changed files with 251 additions and 0 deletions
+27
View File
@@ -116,9 +116,18 @@ func Login(db store.IStore) echo.HandlerFunc {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
}
ipKey := "ip:" + c.RealIP()
userKey := "user:" + username
if !globalLoginThrottle.allow(ipKey) || !globalLoginThrottle.allow(userKey) {
log.Warnf("Login throttled for user %s (%s)", username, c.Request().RemoteAddr)
return tooManyRequests(c.Response())
}
dbuser, err := db.GetUserByName(username)
if err != nil {
log.Warnf("Invalid credentials. Cannot query user %s from DB (%s)", username, c.Request().RemoteAddr)
globalLoginThrottle.recordFailure(ipKey)
globalLoginThrottle.recordFailure(userKey)
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid credentials"})
}
@@ -136,6 +145,8 @@ func Login(db store.IStore) echo.HandlerFunc {
}
if userCorrect && passwordCorrect {
globalLoginThrottle.recordSuccess(ipKey)
globalLoginThrottle.recordSuccess(userKey)
if dbuser.TOTPEnabled {
// Password check passed, but this account requires a second
// factor. Stash a short-lived, NOT-yet-authenticated pending
@@ -147,6 +158,7 @@ func Login(db store.IStore) echo.HandlerFunc {
Path: cookiePath,
MaxAge: 300, // 5 minutes to enter the TOTP code
HttpOnly: true,
Secure: util.CookieSecure,
SameSite: http.SameSiteLaxMode,
}
sess.Values["pending_totp_user"] = dbuser.Username
@@ -165,6 +177,8 @@ func Login(db store.IStore) echo.HandlerFunc {
}
log.Warnf("Invalid credentials user %s (%s)", username, c.Request().RemoteAddr)
globalLoginThrottle.recordFailure(ipKey)
globalLoginThrottle.recordFailure(userKey)
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid credentials"})
}
}
@@ -186,6 +200,7 @@ func finalizeLoginSession(c echo.Context, dbuser model.User, rememberMe bool) er
Path: cookiePath,
MaxAge: ageMax,
HttpOnly: true,
Secure: util.CookieSecure,
SameSite: http.SameSiteLaxMode,
}
@@ -210,6 +225,7 @@ func finalizeLoginSession(c echo.Context, dbuser model.User, rememberMe bool) er
cookie.Value = tokenUID
cookie.MaxAge = ageMax
cookie.HttpOnly = true
cookie.Secure = util.CookieSecure
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
@@ -236,6 +252,13 @@ func VerifyTOTPLogin(db store.IStore) echo.HandlerFunc {
code, _ := data["code"].(string)
ipKey := "ip:" + c.RealIP()
userKey := "totp:" + pendingUsername
if !globalLoginThrottle.allow(ipKey) || !globalLoginThrottle.allow(userKey) {
log.Warnf("TOTP login throttled for user %s (%s)", pendingUsername, c.Request().RemoteAddr)
return tooManyRequests(c.Response())
}
dbuser, err := db.GetUserByName(pendingUsername)
if err != nil {
log.Warnf("Invalid pending TOTP login. Cannot query user %s from DB (%s)", pendingUsername, c.Request().RemoteAddr)
@@ -244,8 +267,12 @@ func VerifyTOTPLogin(db store.IStore) echo.HandlerFunc {
if !dbuser.TOTPEnabled || !auth.Validate(dbuser.TOTPSecret, code, time.Now()) {
log.Warnf("Invalid TOTP code for user %s (%s)", pendingUsername, c.Request().RemoteAddr)
globalLoginThrottle.recordFailure(ipKey)
globalLoginThrottle.recordFailure(userKey)
return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
}
globalLoginThrottle.recordSuccess(ipKey)
globalLoginThrottle.recordSuccess(userKey)
// clear the pending state before finalizing the real session
delete(sess.Values, "pending_totp_user")