R-35: an ended session cannot return after a failed save (password fingerprint in the file; a failed revoking save removes it) — security review
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -26,14 +26,22 @@ import (
|
||||
// invalidateAllSessions (password change, claim reset) write the file at once, so an ended session stays ended
|
||||
// across a restart (TestR35_LogoutEndsSessionAcrossRestart, TestR35_InvalidateAllEndsSessionAcrossRestart).
|
||||
//
|
||||
// Two guards keep a REVOKED session from coming back if a write fails (security review 2026-10-08):
|
||||
// - the file carries a fingerprint of the password hash in force when it was written (CredentialFP); at load, rows
|
||||
// written under another password are dropped — a password change revokes on disk even if its own save failed
|
||||
// (TestR35_PasswordChangeRevokesEvenIfSaveFailed);
|
||||
// - a revoking save (logout, invalidateAllSessions) that fails removes the file instead, so the restart starts with
|
||||
// no sessions rather than the stale ones (TestR35_FailedLogoutSaveRemovesFile).
|
||||
//
|
||||
// A missing file is normal; an unreadable or corrupt one is logged and the server starts with no sessions — never
|
||||
// fatal, and the failure direction is "sign in again", never "signed in" (TestR35_CorruptFileStartsEmpty).
|
||||
|
||||
const sessionsFileName = "dashboard-sessions.json"
|
||||
|
||||
type sessionsFile struct {
|
||||
Version int `json:"version"`
|
||||
Sessions []sessionDisk `json:"sessions"`
|
||||
Version int `json:"version"`
|
||||
CredentialFP string `json:"credential_fp"`
|
||||
Sessions []sessionDisk `json:"sessions"`
|
||||
}
|
||||
|
||||
type sessionDisk struct {
|
||||
@@ -48,6 +56,13 @@ func sessionFingerprint(token string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// credentialFingerprint is hex(sha256(the password hash in force)): it changes whenever the password does, from either
|
||||
// source (settings.json or controller.yaml), and reveals nothing the bcrypt hash itself does not.
|
||||
func (s *Server) credentialFingerprint() string {
|
||||
sum := sha256.Sum256([]byte("felhom-dashboard-sessions\x00" + s.effectivePasswordHash()))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s *Server) sessionsPath() string {
|
||||
if s.cfg == nil || s.cfg.Paths.DataDir == "" {
|
||||
return ""
|
||||
@@ -75,6 +90,10 @@ func (s *Server) loadSessions() {
|
||||
logx.Warnf(s.logger, "[web] sessions: %s is not valid JSON, starting with none: %v", path, err)
|
||||
return
|
||||
}
|
||||
if f.CredentialFP != s.credentialFingerprint() {
|
||||
logx.Infof(s.logger, "[web] sessions: %d session(s) on disk were written under another password — dropped, sign in again", len(f.Sessions))
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
loaded, expired, bad := 0, 0, 0
|
||||
s.sessionsMu.Lock()
|
||||
@@ -102,7 +121,7 @@ func (s *Server) saveSessionsLocked() error {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
f := sessionsFile{Version: 1, Sessions: []sessionDisk{}}
|
||||
f := sessionsFile{Version: 1, CredentialFP: s.credentialFingerprint(), Sessions: []sessionDisk{}}
|
||||
for fp, sess := range s.sessions {
|
||||
if !now.Before(sess.expiresAt) {
|
||||
continue
|
||||
@@ -122,6 +141,21 @@ func (s *Server) saveSessionsLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSessionsRevokingLocked is the save for a path that ENDS sessions (logout, invalidateAllSessions). If the write
|
||||
// fails, the file is removed so a restart cannot bring an ended session back; a household then signs in again, which
|
||||
// is the safe direction. Caller holds sessionsMu for writing.
|
||||
func (s *Server) saveSessionsRevokingLocked() {
|
||||
if err := s.saveSessionsLocked(); err == nil {
|
||||
return
|
||||
}
|
||||
path := s.sessionsPath()
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
logx.Errorf(s.logger, "[web] sessions: could not save NOR remove %s after ending a session — an ended session may return after a restart until the password changes: %v", path, err)
|
||||
return
|
||||
}
|
||||
logx.Warnf(s.logger, "[web] sessions: save failed while ending a session — removed %s instead (every session ends at the next restart)", sessionsFileName)
|
||||
}
|
||||
|
||||
// writeSessionsAtomic is tmp + fsync + rename at 0600 — the family.json shape (internal/family saveLocked).
|
||||
func writeSessionsAtomic(path string, b []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
|
||||
Reference in New Issue
Block a user