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
@@ -3813,3 +3813,94 @@ Keine Commits in dieser Session.
|
||||
- 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 +++++++++-
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -142,6 +142,7 @@ func doRefreshSession(c echo.Context) {
|
||||
Path: cookiePath,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: util.CookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
sess.Save(c.Request(), c.Response())
|
||||
@@ -152,6 +153,7 @@ func doRefreshSession(c echo.Context) {
|
||||
cookie.Value = oldCookie.Value
|
||||
cookie.MaxAge = maxAge
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = util.CookieSecure
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
@@ -273,6 +275,7 @@ func clearSession(c echo.Context) {
|
||||
cookie.Path = cookiePath
|
||||
cookie.MaxAge = -1
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = util.CookieSecure
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ var (
|
||||
flagWgConfTemplate string
|
||||
flagBasePath string
|
||||
flagSubnetRanges string
|
||||
flagCookieSecure = true
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -83,6 +84,7 @@ func init() {
|
||||
// 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.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.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port")
|
||||
flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname")
|
||||
@@ -131,6 +133,7 @@ func init() {
|
||||
|
||||
// update runtime config
|
||||
util.DisableLogin = flagDisableLogin
|
||||
util.CookieSecure = flagCookieSecure
|
||||
util.BindAddress = flagBindAddress
|
||||
util.SmtpHostname = flagSmtpHostname
|
||||
util.SmtpPort = flagSmtpPort
|
||||
|
||||
@@ -48,6 +48,17 @@ func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c
|
||||
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
|
||||
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
|
||||
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.Options.Path = cookiePath
|
||||
cookieStore.Options.HttpOnly = true
|
||||
cookieStore.Options.Secure = util.CookieSecure
|
||||
cookieStore.MaxAge(86400 * 7)
|
||||
|
||||
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.Pre(middleware.RemoveTrailingSlash())
|
||||
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.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO
|
||||
e.Validator = NewValidator()
|
||||
|
||||
@@ -28,6 +28,10 @@ var (
|
||||
BasePath string
|
||||
SubnetRanges map[string]([]*net.IPNet)
|
||||
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 (
|
||||
@@ -70,6 +74,7 @@ const (
|
||||
DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS"
|
||||
DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS"
|
||||
DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION"
|
||||
CookieSecureEnvVar = "WGUI_COOKIE_SECURE"
|
||||
)
|
||||
|
||||
func ParseBasePath(basePath string) string {
|
||||
|
||||
Reference in New Issue
Block a user