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
+74 -31
View File
@@ -39,8 +39,10 @@ import (
// every request).
//
// THE RULE: a family cookie never opens the dashboard. RequireAuth reads only `felhom_session`; nothing here ever sets
// it. An app cookie names a store session, so a removed member, a reset password or a logout ends access at the next
// request on every app. Pinned by internal/web/family_gate_test.go.
// it. An app cookie carries a token derived from a store session for that one app host (familyAppToken, R-976), so a
// removed member, a reset password, a logout or a dashboard password change ends access at the next request on every
// app, and what an app's backend sees cannot be turned into a pass for another app. Pinned by
// internal/web/family_gate_test.go and r983_r976_test.go.
const (
familySessionCookie = "felhom_family"
@@ -91,8 +93,10 @@ func (s *Server) familyStore() *family.Store {
return s.fam.store
}
// familyLocked reports whether this visitor or this name is out of tries. Windows slide; a success clears both.
func (s *Server) familyLocked(visitor, name string) bool {
// familyReserve checks whether this visitor or this name is out of tries and, when neither is, counts this try
// against both — one step under one lock (R-983, v0.324.0), so parallel tries cannot all pass a check that none of
// them has counted yet. Windows slide; a success clears both (familyCleared). It reports whether the try may go ahead.
func (s *Server) familyReserve(visitor, name string) bool {
s.familyStore()
now := s.gateNow()
s.fam.mu.Lock()
@@ -116,17 +120,14 @@ func (s *Server) familyLocked(visitor, name string) bool {
if name != "" {
n = prune(s.fam.name, name, familyNameWindow)
}
return v >= familyVisitorMax || n >= familyNameMax
}
func (s *Server) familyFailed(visitor, name string) {
now := s.gateNow()
s.fam.mu.Lock()
defer s.fam.mu.Unlock()
if v >= familyVisitorMax || n >= familyNameMax {
return false
}
s.fam.visitor[visitor] = append(s.fam.visitor[visitor], now)
if name != "" {
s.fam.name[name] = append(s.fam.name[name], now)
}
return true
}
func (s *Server) familyCleared(visitor, name string) {
@@ -152,9 +153,28 @@ func (s *Server) familyRDHost(rd string) (string, bool) {
return host, true
}
// familyAppToken is what an app host ever sees of a family session (R-976, v0.324.0): a token DERIVED from the
// session for that one host, never the session itself. Before, the app cookie carried the session id in clear; every
// family-gated app's backend receives its cookies, and a session id lifted from one app, set as the family session
// cookie on the dashboard host, minted passes into EVERY family app for 30 days. A derived token cannot be turned back
// into the session, and it opens only its own host. Pinned by TestR976_AnAppCookieCannotBeLiftedIntoAnotherApp.
func (s *Server) familyAppToken(host, sid string) string {
return s.gateMAC("famapp", host, sid)
}
// familySessionForAppToken finds the session an app token was derived from (still valid, as Store.Valid judges it).
func (s *Server) familySessionForAppToken(host, at string) (family.Session, bool) {
if at == "" {
return family.Session{}, false
}
return s.familyStore().Find(func(id string) bool {
return hmac.Equal([]byte(s.familyAppToken(host, id)), []byte(at))
})
}
type familyToken struct {
Host string `json:"h"`
Sid string `json:"s"`
App string `json:"a"` // the per-app token (familyAppToken), never the session id
Exp int64 `json:"e"`
Nonce string `json:"n"`
RD string `json:"r"`
@@ -164,14 +184,14 @@ type familyToken struct {
func (s *Server) mintFamilyToken(host, sid, rd string) string {
nb := make([]byte, 12)
_, _ = rand.Read(nb)
t := familyToken{Host: host, Sid: sid, Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
t := familyToken{Host: host, App: s.familyAppToken(host, sid), Exp: s.gateNow().Add(gateTokenLife).Unix(), Nonce: hex.EncodeToString(nb), RD: rd}
t.MAC = s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD)
b, _ := json.Marshal(t)
return base64.RawURLEncoding.EncodeToString(b)
}
// takeFamilyToken checks a token for host, uses it up, and returns the session and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
// takeFamilyToken checks a token for host, uses it up, and returns the per-app token and where the browser was going.
func (s *Server) takeFamilyToken(raw, host string) (at, rd string, err error) {
b, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return "", "", errors.New("malformed")
@@ -180,7 +200,7 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
if json.Unmarshal(b, &t) != nil {
return "", "", errors.New("malformed")
}
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.Sid, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
if !hmac.Equal([]byte(t.MAC), []byte(s.gateMAC("ftoken", t.Host, t.App, strconv.FormatInt(t.Exp, 10), t.Nonce, t.RD))) {
return "", "", errors.New("bad signature")
}
if t.Host != host {
@@ -202,11 +222,13 @@ func (s *Server) takeFamilyToken(raw, host string) (sid, rd string, err error) {
return "", "", errors.New("already used")
}
s.gate.used["f:"+t.Nonce] = time.Unix(t.Exp, 0).Add(time.Second)
return t.Sid, t.RD, nil
return t.App, t.RD, nil
}
// familyAppCookieSession: "<sid>.<unix expiry>.<mac over host+sid+expiry>" → the session, if the cookie is genuine for
// this host, unexpired, and the store still holds the session (a removed member / reset / logout fails here).
// familyAppCookieSession: "<app token>.<unix expiry>.<mac over host+token+expiry>" → the session, if the cookie is
// genuine for this host, unexpired, and the store still holds the session the token was derived from (a removed
// member / reset / logout / dashboard password change fails here). A cookie of the old "<session id>.…" shape is
// refused: the browser goes through the sign-in start once more and gets the new shape.
func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Session, bool) {
c, err := r.Cookie(familyAppCookie)
if err != nil {
@@ -223,7 +245,7 @@ func (s *Server) familyAppCookieSession(r *http.Request, host string) (family.Se
if !hmac.Equal([]byte(parts[2]), []byte(s.gateMAC("famcookie", host, parts[0], parts[1]))) {
return family.Session{}, false
}
return s.familyStore().Valid(parts[0])
return s.familySessionForAppToken(host, parts[0])
}
func familyRefuse(w http.ResponseWriter, code int) {
@@ -256,9 +278,9 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
return
}
if u, err := url.Parse(uri); err == nil && u.Path == familyCallbackURI {
sid, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
at, rd, err := s.takeFamilyToken(u.Query().Get("t"), host)
if err == nil {
if _, ok := s.familyStore().Valid(sid); !ok {
if _, ok := s.familySessionForAppToken(host, at); !ok {
err = errors.New("the session ended")
}
}
@@ -269,7 +291,7 @@ func (s *Server) ServeFamilyGateAuth(w http.ResponseWriter, r *http.Request) {
}
exp := strconv.FormatInt(s.gateNow().Add(family.SessionLife).Unix(), 10)
http.SetCookie(w, &http.Cookie{
Name: familyAppCookie, Value: sid + "." + exp + "." + s.gateMAC("famcookie", host, sid, exp), Path: "/",
Name: familyAppCookie, Value: at + "." + exp + "." + s.gateMAC("famcookie", host, at, exp), Path: "/",
MaxAge: int(family.SessionLife.Seconds()), HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
s.logger.Printf("[INFO] [web] family gate %s: a signed-in browser passed — visitor %s", app, clientIP(r))
@@ -356,14 +378,16 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
visitor := rateKey(r)
if s.familyLocked(visitor, name) {
// R-983 (v0.324.0): the try is counted BEFORE the password is checked, in the same step as the lock check, so
// parallel tries cannot all pass a check none of them has counted yet. A right password clears both counts.
// Pinned by TestR983_ParallelFamilySignInsHitTheLimit.
if !s.familyReserve(visitor, name) {
s.logger.Printf("[WARN] [web] family sign-in: too many wrong tries — visitor %s, name %q", visitor, name)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.locked"), http.StatusTooManyRequests)
return
}
st := s.familyStore()
if st == nil || !st.Verify(name, r.FormValue("password")) {
s.familyFailed(visitor, name)
s.logger.Printf("[WARN] [web] family sign-in failed — visitor %s", visitor)
s.renderFamilyPage(w, r, rd, s.msg(r, "family_gate.msg.wrong"), http.StatusUnauthorized)
return
@@ -385,12 +409,30 @@ func (s *Server) ServeFamilyLogin(w http.ResponseWriter, r *http.Request) {
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_in"), http.StatusOK)
}
// familyLogoutToken is the sign-out form's token, bound to the family session it ends (R-983, v0.324.0). The sign-in
// form's token (familyFormToken) is the same for every visitor, so a stranger could fetch one and use it; this one
// names the session, which another page never sees.
func (s *Server) familyLogoutToken(sid string) string {
return s.gateMAC("famlogout", sid)
}
// ServeFamilyLogout is /__family/logout: the family session ends, and every app cookie minted from it with it.
// R-983 (v0.324.0): only a POST from the sign-out form (familygate.html), carrying familyLogoutToken. Anything else —
// a GET, or a form another page built — changes nothing and shows the family page. Pinned by
// TestR983_FamilyLogoutNeedsItsToken.
func (s *Server) ServeFamilyLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(familySessionCookie); err == nil {
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
c, err := r.Cookie(familySessionCookie)
if r.Method != http.MethodPost || err != nil {
s.renderFamilyPage(w, r, "", "", http.StatusOK)
return
}
if !hmac.Equal([]byte(r.FormValue("_ft")), []byte(s.familyLogoutToken(c.Value))) {
s.logger.Printf("[WARN] [web] family sign-out refused: the form's token does not match the session — visitor %s", clientIP(r))
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.form_expired"), http.StatusForbidden)
return
}
if err := s.familyStore().EndSession(c.Value); err != nil {
s.logger.Printf("[ERROR] [web] family sign-out: %v", err)
}
http.SetCookie(w, &http.Cookie{Name: familySessionCookie, Value: "", Path: familyCookiePath, MaxAge: -1, HttpOnly: true, Secure: true})
s.renderFamilyPage(w, r, "", s.msg(r, "family_gate.msg.signed_out"), http.StatusOK)
@@ -406,8 +448,9 @@ func (s *Server) renderFamilyPage(w http.ResponseWriter, r *http.Request, rd, no
}
}
}
if _, ok := s.familySessionFromCookie(r); ok {
if se, ok := s.familySessionFromCookie(r); ok {
data["SignedIn"] = true
data["LogoutToken"] = s.familyLogoutToken(se.ID)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")