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
+91
View File
@@ -3813,3 +3813,94 @@ Keine Commits in dieser Session.
- wireguard/service.go | 23 ++++++++++++----- - wireguard/service.go | 23 ++++++++++++-----
--- ---
## 2026-07-29 14:29 14:31 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 1539c58 Normalize AllocatedIPs/ExtraAllowedIPs to network address before ip route replace
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:34 14:34 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:35 14:36 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:39 14:39 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:40 14:40 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:40 14:41 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
## 2026-07-29 14:41 14:41 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- templates/wg.conf | 2 +-
- util/util.go | 21 +++++++++-
---
+98
View File
@@ -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
}
+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"}) 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) dbuser, err := db.GetUserByName(username)
if err != nil { if err != nil {
log.Warnf("Invalid credentials. Cannot query user %s from DB (%s)", username, c.Request().RemoteAddr) 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"}) return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Invalid credentials"})
} }
@@ -136,6 +145,8 @@ func Login(db store.IStore) echo.HandlerFunc {
} }
if userCorrect && passwordCorrect { if userCorrect && passwordCorrect {
globalLoginThrottle.recordSuccess(ipKey)
globalLoginThrottle.recordSuccess(userKey)
if dbuser.TOTPEnabled { if dbuser.TOTPEnabled {
// Password check passed, but this account requires a second // Password check passed, but this account requires a second
// factor. Stash a short-lived, NOT-yet-authenticated pending // factor. Stash a short-lived, NOT-yet-authenticated pending
@@ -147,6 +158,7 @@ func Login(db store.IStore) echo.HandlerFunc {
Path: cookiePath, Path: cookiePath,
MaxAge: 300, // 5 minutes to enter the TOTP code MaxAge: 300, // 5 minutes to enter the TOTP code
HttpOnly: true, HttpOnly: true,
Secure: util.CookieSecure,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
sess.Values["pending_totp_user"] = dbuser.Username 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) 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"}) 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, Path: cookiePath,
MaxAge: ageMax, MaxAge: ageMax,
HttpOnly: true, HttpOnly: true,
Secure: util.CookieSecure,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
@@ -210,6 +225,7 @@ func finalizeLoginSession(c echo.Context, dbuser model.User, rememberMe bool) er
cookie.Value = tokenUID cookie.Value = tokenUID
cookie.MaxAge = ageMax cookie.MaxAge = ageMax
cookie.HttpOnly = true cookie.HttpOnly = true
cookie.Secure = util.CookieSecure
cookie.SameSite = http.SameSiteLaxMode cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie) c.SetCookie(cookie)
@@ -236,6 +252,13 @@ func VerifyTOTPLogin(db store.IStore) echo.HandlerFunc {
code, _ := data["code"].(string) 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) dbuser, err := db.GetUserByName(pendingUsername)
if err != nil { if err != nil {
log.Warnf("Invalid pending TOTP login. Cannot query user %s from DB (%s)", pendingUsername, c.Request().RemoteAddr) 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()) { if !dbuser.TOTPEnabled || !auth.Validate(dbuser.TOTPSecret, code, time.Now()) {
log.Warnf("Invalid TOTP code for user %s (%s)", pendingUsername, c.Request().RemoteAddr) 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"}) return c.JSON(http.StatusUnauthorized, jsonHTTPResponse{false, "Invalid code"})
} }
globalLoginThrottle.recordSuccess(ipKey)
globalLoginThrottle.recordSuccess(userKey)
// clear the pending state before finalizing the real session // clear the pending state before finalizing the real session
delete(sess.Values, "pending_totp_user") delete(sess.Values, "pending_totp_user")
+3
View File
@@ -142,6 +142,7 @@ func doRefreshSession(c echo.Context) {
Path: cookiePath, Path: cookiePath,
MaxAge: maxAge, MaxAge: maxAge,
HttpOnly: true, HttpOnly: true,
Secure: util.CookieSecure,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
} }
sess.Save(c.Request(), c.Response()) sess.Save(c.Request(), c.Response())
@@ -152,6 +153,7 @@ func doRefreshSession(c echo.Context) {
cookie.Value = oldCookie.Value cookie.Value = oldCookie.Value
cookie.MaxAge = maxAge cookie.MaxAge = maxAge
cookie.HttpOnly = true cookie.HttpOnly = true
cookie.Secure = util.CookieSecure
cookie.SameSite = http.SameSiteLaxMode cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie) c.SetCookie(cookie)
} }
@@ -273,6 +275,7 @@ func clearSession(c echo.Context) {
cookie.Path = cookiePath cookie.Path = cookiePath
cookie.MaxAge = -1 cookie.MaxAge = -1
cookie.HttpOnly = true cookie.HttpOnly = true
cookie.Secure = util.CookieSecure
cookie.SameSite = http.SameSiteLaxMode cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie) c.SetCookie(cookie)
} }
+3
View File
@@ -53,6 +53,7 @@ var (
flagWgConfTemplate string flagWgConfTemplate string
flagBasePath string flagBasePath string
flagSubnetRanges string flagSubnetRanges string
flagCookieSecure = true
) )
const ( const (
@@ -83,6 +84,7 @@ func init() {
// command-line flags and env variables // command-line flags and env variables
flag.BoolVar(&flagDisableLogin, "disable-login", util.LookupEnvOrBool("DISABLE_LOGIN", flagDisableLogin), "Disable authentication on the app. This is potentially dangerous.") flag.BoolVar(&flagDisableLogin, "disable-login", util.LookupEnvOrBool("DISABLE_LOGIN", flagDisableLogin), "Disable authentication on the app. This is potentially dangerous.")
flag.StringVar(&flagBindAddress, "bind-address", util.LookupEnvOrString("BIND_ADDRESS", flagBindAddress), "Address:Port to which the app will be bound.") flag.StringVar(&flagBindAddress, "bind-address", util.LookupEnvOrString("BIND_ADDRESS", flagBindAddress), "Address:Port to which the app will be bound.")
flag.BoolVar(&flagCookieSecure, "cookie-secure", util.LookupEnvOrBool(util.CookieSecureEnvVar, flagCookieSecure), "Set the Secure flag on the session cookie. Only disable for a deliberately HTTP-only LAN deployment.")
flag.StringVar(&flagSmtpHostname, "smtp-hostname", util.LookupEnvOrString("SMTP_HOSTNAME", flagSmtpHostname), "SMTP Hostname") flag.StringVar(&flagSmtpHostname, "smtp-hostname", util.LookupEnvOrString("SMTP_HOSTNAME", flagSmtpHostname), "SMTP Hostname")
flag.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port") flag.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port")
flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname") flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname")
@@ -131,6 +133,7 @@ func init() {
// update runtime config // update runtime config
util.DisableLogin = flagDisableLogin util.DisableLogin = flagDisableLogin
util.CookieSecure = flagCookieSecure
util.BindAddress = flagBindAddress util.BindAddress = flagBindAddress
util.SmtpHostname = flagSmtpHostname util.SmtpHostname = flagSmtpHostname
util.SmtpPort = flagSmtpPort util.SmtpPort = flagSmtpPort
+24
View File
@@ -48,6 +48,17 @@ func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c
return tmpl.ExecuteTemplate(w, "base.html", data) return tmpl.ExecuteTemplate(w, "base.html", data)
} }
// hstsMaxAge returns a one-year HSTS max-age when cookies are marked Secure
// (implying an HTTPS deployment), or 0 (no HSTS header at all) otherwise -
// sending HSTS over a deliberately HTTP-only deployment would be pointless
// and could lock out an admin who later can't reach it over HTTPS.
func hstsMaxAge(cookieSecure bool) int {
if cookieSecure {
return 31536000
}
return 0
}
// New function // New function
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo { func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
e := echo.New() e := echo.New()
@@ -57,6 +68,7 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
cookieStore := sessions.NewCookieStore(secret[:32], secret[32:]) cookieStore := sessions.NewCookieStore(secret[:32], secret[32:])
cookieStore.Options.Path = cookiePath cookieStore.Options.Path = cookiePath
cookieStore.Options.HttpOnly = true cookieStore.Options.HttpOnly = true
cookieStore.Options.Secure = util.CookieSecure
cookieStore.MaxAge(86400 * 7) cookieStore.MaxAge(86400 * 7)
e.Use(session.Middleware(cookieStore)) e.Use(session.Middleware(cookieStore))
@@ -166,6 +178,18 @@ func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo
e.Logger.SetLevel(lvl) e.Logger.SetLevel(lvl)
e.Pre(middleware.RemoveTrailingSlash()) e.Pre(middleware.RemoveTrailingSlash())
e.Use(middleware.LoggerWithConfig(logConfig)) e.Use(middleware.LoggerWithConfig(logConfig))
// Basic hardening headers. No Content-Security-Policy here: the
// existing templates rely heavily on inline <script> blocks, and a CSP
// strict enough to matter would need 'unsafe-inline' anyway, making it
// mostly cosmetic - not worth the risk of quietly breaking the UI.
e.Use(middleware.SecureWithConfig(middleware.SecureConfig{
XSSProtection: "1; mode=block",
ContentTypeNosniff: "nosniff",
XFrameOptions: "SAMEORIGIN",
ReferrerPolicy: "same-origin",
HSTSMaxAge: hstsMaxAge(util.CookieSecure),
HSTSExcludeSubdomains: false,
}))
e.HideBanner = true e.HideBanner = true
e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO
e.Validator = NewValidator() e.Validator = NewValidator()
+5
View File
@@ -28,6 +28,10 @@ var (
BasePath string BasePath string
SubnetRanges map[string]([]*net.IPNet) SubnetRanges map[string]([]*net.IPNet)
SubnetRangesOrder []string SubnetRangesOrder []string
// CookieSecure sets the Secure flag on the session cookie, so browsers
// never send it over plain HTTP. Defaults to true; only disable it for
// a deliberately HTTP-only LAN deployment (see COOKIE_SECURE_ENV_VAR).
CookieSecure bool
) )
const ( const (
@@ -70,6 +74,7 @@ const (
DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS" DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS"
DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS" DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS"
DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION" DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION"
CookieSecureEnvVar = "WGUI_COOKIE_SECURE"
) )
func ParseBasePath(basePath string) string { func ParseBasePath(basePath string) string {