v0.324.0: the database password comes back with the data (R-1106, R-127b, R-926); restic 0.19.1 (R-1107); R-1108, R-1065, R-1017; dashboard hardening (R-983, R-976)
gates / gates (push) Successful in 44s

This commit is contained in:
2026-10-16 22:46:00 +02:00
parent 00a0d91976
commit f75b3aa778
212 changed files with 2414 additions and 398 deletions
+77 -25
View File
@@ -2,6 +2,7 @@ package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
@@ -164,31 +165,19 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
s.loginAttemptMu.Lock()
attempt := s.loginAttempts[ip]
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
// Window expired — reset
attempt = nil
delete(s.loginAttempts, ip)
}
if attempt != nil && attempt.count >= loginMaxAttempts {
s.loginAttemptMu.Unlock()
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, attempt.count)
// R-983 (v0.324.0): the try is RESERVED before the password is compared, under the one lock — check and count
// are a single step. Before, the count was taken only after the slow compare, so N parallel tries all passed the
// check and the 5-per-minute limit became "as many as arrive at once". A right password clears the counter below,
// so the household loses nothing by its own try being counted first. Pinned by TestR983_ParallelLoginsHitTheLimit.
if n, ok := s.reserveLoginAttempt(ip); !ok {
s.logger.Printf("[WARN] [web] Login rate limited for %s (%d attempts)", ip, n)
s.renderLogin(w, r, s.msg(r, "login.msg.rate_limited"), "")
return
}
s.loginAttemptMu.Unlock()
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil {
if err := s.comparePassword([]byte(effectiveHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] [web] Failed login from %s", ip)
s.loginAttemptMu.Lock()
if s.loginAttempts[ip] == nil {
s.loginAttempts[ip] = &loginAttempt{}
}
s.loginAttempts[ip].count++
s.loginAttempts[ip].lastFail = time.Now()
s.loginAttemptMu.Unlock()
s.renderLogin(w, r, s.msg(r, "login.msg.wrong_password"), "")
return
}
@@ -221,14 +210,50 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[INFO] [web] Login from %s", ip)
// Redirect to ?next= target if provided, otherwise to dashboard
redirectTo := "/"
if nextURL != "" && strings.HasPrefix(nextURL, "/") {
redirectTo = nextURL
}
http.Redirect(w, r, redirectTo, http.StatusFound)
// Redirect to ?next= target if provided, otherwise to dashboard. R-983 (v0.324.0): through safeBackPath, the one
// guard for a destination taken from a request — a bare HasPrefix("/") let "//host" and "/\host" send a browser
// that had just signed in to another site. Pinned by TestR983_LoginNextStaysOnTheBox.
http.Redirect(w, r, safeBackPath(nextURL), http.StatusFound)
}
// reserveLoginAttempt counts one sign-in try for this visitor BEFORE the password is compared and reports whether it
// may go ahead (and the count it found). Check and count happen under one lock, so parallel tries cannot all pass a
// check that none of them has counted yet. A window that has passed starts clean.
func (s *Server) reserveLoginAttempt(ip string) (int, bool) {
s.loginAttemptMu.Lock()
defer s.loginAttemptMu.Unlock()
if s.loginAttempts == nil {
s.loginAttempts = map[string]*loginAttempt{}
}
a := s.loginAttempts[ip]
if a != nil && time.Since(a.lastFail) > loginWindowDuration {
a = nil
}
if a == nil {
a = &loginAttempt{}
s.loginAttempts[ip] = a
}
if a.count >= loginMaxAttempts {
return a.count, false
}
a.count++
a.lastFail = time.Now()
return a.count, true
}
// comparePassword is bcrypt's compare; passwordCompare replaces it in tests (a slow compare makes a race measurable).
func (s *Server) comparePassword(hash, pw []byte) error {
if s.passwordCompare != nil {
return s.passwordCompare(hash, pw)
}
return bcrypt.CompareHashAndPassword(hash, pw)
}
// handleLogout ends the presented dashboard session. It runs inside RequireAuth, BEFORE CsrfProtect, so it checks the
// session's CSRF token itself (R-983, v0.324.0): without it any page — another site, or an app on a sibling
// subdomain, which is same-site and so gets the Lax cookie — could sign the household out. A GET changes nothing and
// goes to the dashboard; the dashboard's own sign-out control is a POST form carrying the token (layout.html).
// A POST with no valid session has nothing to end and only clears the cookie. Pinned by TestR983_LogoutNeedsCSRF.
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusFound)
@@ -238,6 +263,16 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
if expected := s.csrfTokenForSession(cookie.Value); expected != "" {
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "logout without the session's token")
return
}
}
s.sessionsMu.Lock()
delete(s.sessions, sessionFingerprint(cookie.Value))
s.saveSessionsRevokingLocked() // R-35: a signed-out cookie stays signed out across a restart
@@ -304,6 +339,23 @@ func (s *Server) invalidateAllSessions() {
s.logger.Printf("[INFO] [web] All sessions invalidated (cleared %d)", count)
}
// endEverySession is what a dashboard password change and a claim/reset do (R-976, v0.324.0): every dashboard session
// ends (invalidateAllSessions), every family session — each member's and the household's — ends, and the gate key is
// replaced, so every setup-gate pass and every family app cookie stops opening its app at the next request. Before,
// only the dashboard sessions ended, so a session copied out of a family app kept working for up to 30 days after the
// household had changed its password. Family members keep their own passwords and sign in again; the household's
// browser passes again through its new dashboard session. Pinned by TestR976_PasswordChangeEndsFamilyAndGateSessions
// and TestR976_ClaimResetEndsFamilyAndGateSessions.
func (s *Server) endEverySession(why string) {
s.invalidateAllSessions()
n, err := s.familyStore().EndAll()
if err != nil {
s.logger.Printf("[ERROR] [web] %s: the family sessions were ended but the family list could not be saved: %v", why, err)
}
s.rotateGateKey()
s.logger.Printf("[INFO] [web] %s: every dashboard session, %d family session(s) and every app gate pass ended", why, n)
}
func (s *Server) cleanupSessions() {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()