diff --git a/DEVLOG.md b/DEVLOG.md index 6132f82..1b87a34 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++- + +--- diff --git a/handler/login_throttle.go b/handler/login_throttle.go new file mode 100644 index 0000000..38ea5ab --- /dev/null +++ b/handler/login_throttle.go @@ -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 +} diff --git a/handler/routes.go b/handler/routes.go index 741bc84..bc54698 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -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") diff --git a/handler/session.go b/handler/session.go index 7c25e45..f389fec 100644 --- a/handler/session.go +++ b/handler/session.go @@ -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) } diff --git a/main.go b/main.go index 77405b8..6c04fe6 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/router/router.go b/router/router.go index 6dc3c00..d62b8c8 100644 --- a/router/router.go +++ b/router/router.go @@ -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