diff --git a/internal/api/csrf_store.go b/internal/api/csrf_store.go index 32a7ccc..558c592 100644 --- a/internal/api/csrf_store.go +++ b/internal/api/csrf_store.go @@ -25,9 +25,11 @@ type CSRFToken struct { type CSRFTokenStore struct { tokens map[string]*CSRFToken mu sync.RWMutex + saveMu sync.Mutex // Serializes disk writes to prevent save corruption dataPath string saveTicker *time.Ticker stopChan chan bool + stopOnce sync.Once // Ensures Stop() can only close channel once } func csrfSessionKey(sessionID string) string { @@ -99,9 +101,11 @@ func (c *CSRFTokenStore) backgroundWorker() { // Stop gracefully stops the CSRF store func (c *CSRFTokenStore) Stop() { - c.saveTicker.Stop() - c.stopChan <- true - c.save() + c.stopOnce.Do(func() { + c.saveTicker.Stop() + close(c.stopChan) // Close instead of send to signal all readers + c.save() + }) } // GenerateCSRFToken creates a new CSRF token for a session @@ -183,6 +187,10 @@ func (c *CSRFTokenStore) cleanup() { // save persists CSRF tokens to disk func (c *CSRFTokenStore) save() { + // Serialize all disk writes to prevent corruption + c.saveMu.Lock() + defer c.saveMu.Unlock() + c.mu.RLock() defer c.mu.RUnlock() c.saveUnsafe() diff --git a/internal/api/recovery_tokens.go b/internal/api/recovery_tokens.go index 16cee86..04a2069 100644 --- a/internal/api/recovery_tokens.go +++ b/internal/api/recovery_tokens.go @@ -149,6 +149,15 @@ func (r *RecoveryTokenStore) ValidateRecoveryTokenConstantTime(providedToken str // Need to upgrade to write lock to mark as used r.mu.RUnlock() r.mu.Lock() + + // CRITICAL: Re-check token.Used after acquiring write lock + // Prevents replay attack where two concurrent requests both pass initial check + if token.Used { + r.mu.Unlock() + r.mu.RLock() + return false + } + token.Used = true token.UsedAt = time.Now() token.IP = ip diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 2a5198c..a538d15 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -341,8 +341,12 @@ func (h *Hub) Run() { Data: map[string]string{"message": "Connected to Pulse WebSocket"}, } if data, err := json.Marshal(welcomeMsg); err == nil { - // Check if client is still registered before sending - if _, ok := h.clients[client]; ok { + // Check if client is still registered before sending (must hold lock) + h.mu.RLock() + _, stillRegistered := h.clients[client] + h.mu.RUnlock() + + if stillRegistered { log.Info().Str("client", client.id).Msg("Sending welcome message") select { case client.send <- data: @@ -368,8 +372,12 @@ func (h *Hub) Run() { Data: sanitizeData(stateData), } if data, err := json.Marshal(initialMsg); err == nil { - // Check if client is still registered before sending - if _, ok := h.clients[client]; ok { + // Check if client is still registered before sending (must hold lock) + h.mu.RLock() + _, stillRegistered := h.clients[client] + h.mu.RUnlock() + + if stillRegistered { log.Info().Str("client", client.id).Int("dataLen", len(data)).Int("dataKB", len(data)/1024).Msg("Sending initial state to client") select {