diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 5f55f8877..0cda77e13 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -240,6 +240,12 @@ the manifest's support-floor row. 5. Keep release-grade updater trust fail-closed across `internal/agentupdate/`, `internal/dockeragent/`, and the shared `internal/api/unified_agent.go` download helpers. When release builds embed trusted update signing keys, published agent binaries and installer assets must carry detached `.sig` plus `.sshsig` sidecars; updater/runtime paths must require `X-Signature-Ed25519` in addition to `X-Checksum-Sha256`, and installer-owned download flows must require the matching base64-encoded `X-Signature-SSHSIG`, instead of silently downgrading to checksum-only trust. 6. Keep shared `internal/api/` helper edits isolated from agent lifecycle semantics: Patrol-specific status transport or alert-trigger wiring changes in shared handlers must not bleed into auto-register, installer, or fleet-control behavior unless this contract moves in the same slice. The same isolation rule applies to AI settings payload work in `internal/api/ai_handlers.go`: provider auth fields, masked-secret echoes, and provider-test model selection remain AI/runtime plus API-contract ownership and must not be reinterpreted as lifecycle setup or registration semantics just because they share backend helper layers. + The same isolation rule applies to CSRF token-store behavior in + `internal/api/csrf_store.go`: lifecycle-adjacent browser flows may rely on + the shared API/security layer to keep parallel replacement-token retries + valid for one authenticated session, but retained CSRF hashes are not + install tokens, setup-token state, enrollment authority, or agent credential + continuity. The same shared-helper rule now covers SSO outbound discovery and metadata fetches plus credential-file loads in `internal/api/sso_outbound.go`, `internal/api/saml_service.go`, and `internal/api/oidc_service.go`: lifecycle-adjacent setup or auth work may depend on that shared trust boundary, but it must not fork a second HTTP client, redirect policy, or file-read rule inside lifecycle-local flows. The same shared-helper rule also covers organization membership and cross-organization sharing transport in `internal/api/org_handlers.go` plus diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 7ece9ce32..d341f189f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -179,6 +179,13 @@ Own canonical runtime payload shapes between backend and frontend. 45. `internal/api/relay_mobile_capability.go` shared with `relay-runtime`: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface. 46. `internal/api/resources.go` shared with `unified-resources`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. 47. `internal/api/security.go` shared with `security-privacy`: the security handlers are both a security/privacy control surface and a canonical API payload contract boundary. + That same shared security/API boundary owns CSRF replacement-token + concurrency. When parallel browser mutations arrive with stale or missing + CSRF tokens for the same session, `internal/api/csrf_store.go` may retain + a bounded set of recent unexpired token hashes so each server-issued + replacement can validate its retry. Logout, password-change, and explicit + session revocation must still delete the full session token set rather than + leaving any retained replacement token valid. 48. `internal/api/security_tokens.go` shared with `security-privacy`: the security token handlers are both a security/privacy control surface and a canonical API payload contract boundary. 49. `internal/api/slo.go` shared with `performance-and-scalability`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary. 50. `internal/api/system_settings.go` shared with `security-privacy`: the system settings telemetry and auth controls are both a security/privacy control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index b1f81b78a..b23da43da 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -84,6 +84,11 @@ visibility, and privacy controls to operators. 2. Change security policy, hardening guidance, or supported auth boundaries through `SECURITY.md`. 3. Change telemetry/privacy settings state handling through `frontend-modern/src/components/Settings/useSystemSettingsState.ts`. 4. Change security/auth/token transport behavior through the shared `frontend-modern/src/api/security.ts`, `frontend-modern/src/components/Settings/APITokenManager.tsx`, `frontend-modern/src/components/Settings/apiTokenManagerModel.ts`, `frontend-modern/src/components/Settings/useAPITokenManagerState.ts`, `internal/api/security.go`, `internal/api/security_tokens.go`, and `internal/api/system_settings.go` boundary. + CSRF token-store behavior in `internal/api/csrf_store.go` is part of that + shared browser-auth trust boundary: parallel stale-token mutations may + receive distinct bounded replacement tokens for one session, but explicit + session deletion, password-change invalidation, and logout must invalidate + every retained CSRF hash for that session. 5. Change security/privacy settings presentation through the shared `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`, `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx`, `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx`, `frontend-modern/src/components/Settings/QuickSecuritySetup.tsx`, `frontend-modern/src/components/Settings/SecurityPostureSummary.tsx`, `frontend-modern/src/components/Settings/SSOProviderTypeIcon.tsx`, `frontend-modern/src/utils/securityAuthPresentation.ts`, `frontend-modern/src/utils/securityScorePresentation.ts`, `frontend-modern/src/utils/auditLogPresentation.ts`, and `frontend-modern/src/utils/auditWebhookPresentation.ts` boundary. 6. Change operator-facing telemetry/adoption reporting through `scripts/telemetry_adoption_report.py` together with the privacy disclosure whenever release-identity interpretation changes. 7. Change data-at-rest encryption-key or control-plane magic-link HMAC key and storage-root hardening semantics through `internal/crypto/crypto.go`, `internal/cloudcp/auth/magiclink.go`, `internal/cloudcp/auth/magiclink_store.go`, and `internal/securityutil/secure_storage_dir.go` together so writable-but-not-owned runtime storage mounts stay supported without weakening file-level secrecy. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 9ebfa0f3e..fddbc4764 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -89,6 +89,11 @@ querying, and the operator-facing storage health presentation layer. handoff safety mode for that brief remains an AI runtime/API contract concern and must not move storage or recovery readiness truth into model prose. 4. Route transport changes for storage and recovery endpoints through `internal/api/` and the owning `api-contracts` proof routes + That same adjacent API/security boundary owns CSRF replacement-token + concurrency for browser mutations. Storage and recovery forms may benefit + from the shared retry behavior when parallel requests receive replacement + CSRF cookies, but they must not define storage-local CSRF retention, + alternate retry tokens, or recovery-specific auth bypass semantics. That same adjacent API boundary also owns TrueNAS feature-default semantics for provider-backed recovery: storage and recovery must treat `truenas_disabled` as an explicit platform opt-out, not as the baseline onboarding state for a diff --git a/internal/api/csrf_store.go b/internal/api/csrf_store.go index 6faa8042d..3fa7eac47 100644 --- a/internal/api/csrf_store.go +++ b/internal/api/csrf_store.go @@ -24,7 +24,7 @@ type CSRFToken struct { // CSRFTokenStore handles persistent CSRF token storage type CSRFTokenStore struct { - tokens map[string]*CSRFToken + tokens map[string][]*CSRFToken mu sync.RWMutex saveMu sync.Mutex // Serializes disk writes to prevent save corruption dataPath string @@ -34,6 +34,8 @@ type CSRFTokenStore struct { stopOnce sync.Once } +const maxCSRFTokensPerSession = 8 + func csrfSessionKey(sessionID string) string { return sessionHash(sessionID) } @@ -76,10 +78,10 @@ func (c *CSRFTokenStore) migrateLegacyTokens(data []byte, now time.Time) (bool, continue } - c.tokens[csrfSessionKey(sessionID)] = &CSRFToken{ + c.addTokenUnsafe(csrfSessionKey(sessionID), &CSRFToken{ Hash: csrfTokenHash(record.Token), Expires: record.ExpiresAt, - } + }, now) loaded++ } @@ -117,7 +119,7 @@ func ensureCSRFStore(dataPath string) *CSRFTokenStore { oldStore := csrfStore csrfStore = &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: newDataPath, stopChan: make(chan bool), workerDone: make(chan struct{}), @@ -204,11 +206,12 @@ func (c *CSRFTokenStore) GenerateCSRFToken(sessionID string) string { c.mu.Lock() defer c.mu.Unlock() + now := time.Now() key := csrfSessionKey(sessionID) - c.tokens[key] = &CSRFToken{ + c.addTokenUnsafe(key, &CSRFToken{ Hash: csrfTokenHash(token), - Expires: time.Now().Add(4 * time.Hour), - } + Expires: now.Add(4 * time.Hour), + }, now) // Save immediately for important operations c.saveUnsafe() @@ -221,16 +224,24 @@ func (c *CSRFTokenStore) ValidateCSRFToken(sessionID, token string) bool { c.mu.RLock() defer c.mu.RUnlock() - csrfToken, exists := c.tokens[csrfSessionKey(sessionID)] + candidates, exists := c.tokens[csrfSessionKey(sessionID)] if !exists { return false } - if time.Now().After(csrfToken.Expires) { - return false + now := time.Now() + tokenHash := csrfTokenHash(token) + valid := false + for _, csrfToken := range candidates { + if csrfToken == nil || now.After(csrfToken.Expires) { + continue + } + if subtle.ConstantTimeCompare([]byte(csrfToken.Hash), []byte(tokenHash)) == 1 { + valid = true + } } - return subtle.ConstantTimeCompare([]byte(csrfToken.Hash), []byte(csrfTokenHash(token))) == 1 + return valid } // DeleteCSRFToken removes a CSRF token @@ -242,16 +253,67 @@ func (c *CSRFTokenStore) DeleteCSRFToken(sessionID string) { c.saveUnsafe() } +func (c *CSRFTokenStore) addTokenUnsafe(sessionKey string, token *CSRFToken, now time.Time) { + if token == nil || sessionKey == "" { + return + } + + tokens := c.pruneSessionTokensUnsafe(sessionKey, now) + tokens = append(tokens, token) + if len(tokens) > maxCSRFTokensPerSession { + tokens = tokens[len(tokens)-maxCSRFTokensPerSession:] + } + c.tokens[sessionKey] = tokens +} + +func (c *CSRFTokenStore) pruneSessionTokensUnsafe(sessionKey string, now time.Time) []*CSRFToken { + tokens := c.tokens[sessionKey] + if len(tokens) == 0 { + delete(c.tokens, sessionKey) + return nil + } + + kept := tokens[:0] + for _, token := range tokens { + if token == nil || now.After(token.Expires) { + continue + } + kept = append(kept, token) + } + if len(kept) == 0 { + delete(c.tokens, sessionKey) + return nil + } + if len(kept) > maxCSRFTokensPerSession { + kept = kept[len(kept)-maxCSRFTokensPerSession:] + } + c.tokens[sessionKey] = kept + return kept +} + +func (c *CSRFTokenStore) tokenRecordCountUnsafe() int { + count := 0 + for _, tokens := range c.tokens { + count += len(tokens) + } + return count +} + // cleanup removes expired CSRF tokens func (c *CSRFTokenStore) cleanup() { c.mu.Lock() defer c.mu.Unlock() now := time.Now() - for sessionKey, token := range c.tokens { - if now.After(token.Expires) { - delete(c.tokens, sessionKey) - log.Debug().Str("sessionKey", safePrefixForLog(sessionKey, 8)+"...").Msg("Cleaned up expired CSRF token") + for sessionKey, tokens := range c.tokens { + before := len(tokens) + kept := c.pruneSessionTokensUnsafe(sessionKey, now) + removed := before - len(kept) + if removed > 0 { + log.Debug(). + Str("sessionKey", safePrefixForLog(sessionKey, 8)+"..."). + Int("removed", removed). + Msg("Cleaned up expired CSRF tokens") } } } @@ -278,13 +340,18 @@ func (c *CSRFTokenStore) saveUnsafe() { } // Convert to serializable format - persisted := make([]*CSRFTokenData, 0, len(c.tokens)) - for sessionKey, token := range c.tokens { - persisted = append(persisted, &CSRFTokenData{ - TokenHash: token.Hash, - SessionKey: sessionKey, - ExpiresAt: token.Expires, - }) + persisted := make([]*CSRFTokenData, 0, c.tokenRecordCountUnsafe()) + for sessionKey, tokens := range c.tokens { + for _, token := range tokens { + if token == nil { + continue + } + persisted = append(persisted, &CSRFTokenData{ + TokenHash: token.Hash, + SessionKey: sessionKey, + ExpiresAt: token.Expires, + }) + } } // Marshal tokens @@ -307,7 +374,10 @@ func (c *CSRFTokenStore) saveUnsafe() { return } - log.Debug().Int("count", len(c.tokens)).Msg("CSRF tokens saved to disk") + log.Debug(). + Int("sessions", len(c.tokens)). + Int("tokens", len(persisted)). + Msg("CSRF tokens saved to disk") } // load reads CSRF tokens from disk @@ -322,22 +392,25 @@ func (c *CSRFTokenStore) load() { return } - c.tokens = make(map[string]*CSRFToken) + c.tokens = make(map[string][]*CSRFToken) var current []*CSRFTokenData if err := json.Unmarshal(data, ¤t); err == nil { now := time.Now() + loaded := 0 for _, record := range current { - if record == nil || now.After(record.ExpiresAt) { + if record == nil || record.SessionKey == "" || record.TokenHash == "" || now.After(record.ExpiresAt) { continue } - c.tokens[record.SessionKey] = &CSRFToken{ + c.addTokenUnsafe(record.SessionKey, &CSRFToken{ Hash: record.TokenHash, Expires: record.ExpiresAt, - } + }, now) + loaded++ } log.Info(). - Int("loaded", len(c.tokens)). + Int("loaded", loaded). + Int("sessions", len(c.tokens)). Int("total", len(current)). Msg("CSRF tokens loaded from disk (hashed format)") return diff --git a/internal/api/csrf_store_test.go b/internal/api/csrf_store_test.go index 3cfe75faf..c4d233f26 100644 --- a/internal/api/csrf_store_test.go +++ b/internal/api/csrf_store_test.go @@ -160,7 +160,7 @@ func TestCSRFTokenStore_GenerateAndValidate(t *testing.T) { tmpDir := t.TempDir() store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -192,7 +192,7 @@ func TestCSRFTokenStore_DeleteToken(t *testing.T) { tmpDir := t.TempDir() store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -217,22 +217,22 @@ func TestCSRFTokenStore_DeleteToken(t *testing.T) { func TestCSRFTokenStore_Cleanup(t *testing.T) { store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), } // Add expired token expiredKey := csrfSessionKey("expired-session") - store.tokens[expiredKey] = &CSRFToken{ + store.tokens[expiredKey] = []*CSRFToken{{ Hash: "expired-hash", Expires: time.Now().Add(-1 * time.Hour), // Expired - } + }} // Add valid token validKey := csrfSessionKey("valid-session") - store.tokens[validKey] = &CSRFToken{ + store.tokens[validKey] = []*CSRFToken{{ Hash: "valid-hash", Expires: time.Now().Add(1 * time.Hour), // Not expired - } + }} // Run cleanup store.cleanup() @@ -248,9 +248,41 @@ func TestCSRFTokenStore_Cleanup(t *testing.T) { } } +func TestCSRFTokenStore_CleanupPrunesExpiredTokensWithinSession(t *testing.T) { + store := &CSRFTokenStore{ + tokens: make(map[string][]*CSRFToken), + } + + sessionID := "mixed-session" + expiredToken := "expired-token" + validToken := "valid-token" + store.tokens[csrfSessionKey(sessionID)] = []*CSRFToken{ + { + Hash: csrfTokenHash(expiredToken), + Expires: time.Now().Add(-1 * time.Hour), + }, + { + Hash: csrfTokenHash(validToken), + Expires: time.Now().Add(1 * time.Hour), + }, + } + + store.cleanup() + + if store.ValidateCSRFToken(sessionID, expiredToken) { + t.Fatal("expired token should be pruned from the session") + } + if !store.ValidateCSRFToken(sessionID, validToken) { + t.Fatal("valid token should remain after pruning expired session tokens") + } + if got := len(store.tokens[csrfSessionKey(sessionID)]); got != 1 { + t.Fatalf("expected 1 retained token, got %d", got) + } +} + func TestCSRFTokenStore_ExpiredTokenInvalid(t *testing.T) { store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), } sessionID := "test-session-789" @@ -258,10 +290,10 @@ func TestCSRFTokenStore_ExpiredTokenInvalid(t *testing.T) { // Add token that is already expired key := csrfSessionKey(sessionID) - store.tokens[key] = &CSRFToken{ + store.tokens[key] = []*CSRFToken{{ Hash: csrfTokenHash(token), Expires: time.Now().Add(-1 * time.Second), // Already expired - } + }} // Should be invalid if store.ValidateCSRFToken(sessionID, token) { @@ -271,7 +303,7 @@ func TestCSRFTokenStore_ExpiredTokenInvalid(t *testing.T) { func TestCSRFTokenStore_NonexistentSession(t *testing.T) { store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), } // Should return false for nonexistent session @@ -284,7 +316,7 @@ func TestCSRFTokenStore_MultipleTokens(t *testing.T) { tmpDir := t.TempDir() store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -318,7 +350,7 @@ func TestCSRFTokenStore_RegenerateToken(t *testing.T) { tmpDir := t.TempDir() store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -327,7 +359,7 @@ func TestCSRFTokenStore_RegenerateToken(t *testing.T) { // Generate first token token1 := store.GenerateCSRFToken(sessionID) - // Generate second token for same session (replaces first) + // Generate second token for same session. token2 := store.GenerateCSRFToken(sessionID) // Tokens should be different @@ -335,13 +367,46 @@ func TestCSRFTokenStore_RegenerateToken(t *testing.T) { t.Error("Regenerated token should be different") } - // Only new token should be valid - if store.ValidateCSRFToken(sessionID, token1) { - t.Error("Old token should be invalid after regeneration") + // Recent tokens remain valid so concurrent browser retries do not invalidate + // one another when the server has to issue replacement CSRF cookies. + if !store.ValidateCSRFToken(sessionID, token1) { + t.Error("First token should remain valid after bounded regeneration") } if !store.ValidateCSRFToken(sessionID, token2) { t.Error("New token should be valid") } + + if got := len(store.tokens[csrfSessionKey(sessionID)]); got != 2 { + t.Fatalf("expected 2 recent tokens for session, got %d", got) + } +} + +func TestCSRFTokenStore_BoundsRecentTokensPerSession(t *testing.T) { + tmpDir := t.TempDir() + + store := &CSRFTokenStore{ + tokens: make(map[string][]*CSRFToken), + dataPath: tmpDir, + } + + sessionID := "bounded-session" + generated := make([]string, 0, maxCSRFTokensPerSession+1) + for i := 0; i < maxCSRFTokensPerSession+1; i++ { + generated = append(generated, store.GenerateCSRFToken(sessionID)) + } + + if store.ValidateCSRFToken(sessionID, generated[0]) { + t.Fatal("oldest token should be evicted once the per-session limit is exceeded") + } + for i, token := range generated[1:] { + if !store.ValidateCSRFToken(sessionID, token) { + t.Fatalf("recent token %d should remain valid", i+1) + } + } + + if got := len(store.tokens[csrfSessionKey(sessionID)]); got != maxCSRFTokensPerSession { + t.Fatalf("expected %d retained tokens, got %d", maxCSRFTokensPerSession, got) + } } func TestCSRFTokenStore_SaveAndLoad(t *testing.T) { @@ -349,12 +414,13 @@ func TestCSRFTokenStore_SaveAndLoad(t *testing.T) { // Create store and add a token store1 := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } sessionID := "persist-session" token := store1.GenerateCSRFToken(sessionID) + replacementToken := store1.GenerateCSRFToken(sessionID) // Explicitly save store1.save() @@ -367,7 +433,7 @@ func TestCSRFTokenStore_SaveAndLoad(t *testing.T) { // Create new store and load store2 := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } store2.load() @@ -376,13 +442,16 @@ func TestCSRFTokenStore_SaveAndLoad(t *testing.T) { if !store2.ValidateCSRFToken(sessionID, token) { t.Error("Token should be valid after save/load") } + if !store2.ValidateCSRFToken(sessionID, replacementToken) { + t.Error("Replacement token should be valid after save/load") + } } func TestCSRFTokenStore_LoadNonexistentFile(t *testing.T) { tmpDir := t.TempDir() store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: filepath.Join(tmpDir, "nonexistent"), } @@ -396,7 +465,7 @@ func TestCSRFTokenStore_LoadNonexistentFile(t *testing.T) { func TestCSRFTokenStore_EmptyTokensMap(t *testing.T) { store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), } // Cleanup on empty map should not panic @@ -418,15 +487,15 @@ func TestCSRFTokenStore_SaveUnsafe_MkdirAllError(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: blockedPath, // This is a file, not a directory } // Add a token to save - store.tokens["testkey"] = &CSRFToken{ + store.tokens["testkey"] = []*CSRFToken{{ Hash: "testhash", Expires: time.Now().Add(time.Hour), - } + }} // saveUnsafe should handle error gracefully (logs but doesn't panic) store.saveUnsafe() @@ -454,15 +523,15 @@ func TestCSRFTokenStore_SaveUnsafe_WriteFileError(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: readOnlyDir, } // Add a token - store.tokens["testkey"] = &CSRFToken{ + store.tokens["testkey"] = []*CSRFToken{{ Hash: "testhash", Expires: time.Now().Add(time.Hour), - } + }} // Make directory read-only after it exists (MkdirAll succeeds, WriteFile fails) if err := os.Chmod(readOnlyDir, 0555); err != nil { @@ -493,15 +562,15 @@ func TestCSRFTokenStore_SaveUnsafe_RenameError(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } // Add a token - store.tokens["testkey"] = &CSRFToken{ + store.tokens["testkey"] = []*CSRFToken{{ Hash: "testhash", Expires: time.Now().Add(time.Hour), - } + }} // saveUnsafe should handle rename error gracefully store.saveUnsafe() @@ -526,7 +595,7 @@ func TestCSRFTokenStore_Load_ReadError(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -548,7 +617,7 @@ func TestCSRFTokenStore_Load_InvalidJSON(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -575,7 +644,7 @@ func TestCSRFTokenStore_Load_MigratesLegacyFormat(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -632,7 +701,7 @@ func TestCSRFTokenStore_Load_CurrentFormat_SkipsNilAndExpired(t *testing.T) { } store := &CSRFTokenStore{ - tokens: make(map[string]*CSRFToken), + tokens: make(map[string][]*CSRFToken), dataPath: tmpDir, } @@ -650,6 +719,50 @@ func TestCSRFTokenStore_Load_CurrentFormat_SkipsNilAndExpired(t *testing.T) { } } +func TestCSRFTokenStore_Load_CurrentFormat_RetainsRecentTokensForSession(t *testing.T) { + tmpDir := t.TempDir() + sessionID := "persisted-concurrent-session" + sessionKey := csrfSessionKey(sessionID) + records := []*CSRFTokenData{ + { + TokenHash: csrfTokenHash("token-one"), + SessionKey: sessionKey, + ExpiresAt: time.Now().Add(time.Hour), + }, + { + TokenHash: csrfTokenHash("token-two"), + SessionKey: sessionKey, + ExpiresAt: time.Now().Add(time.Hour), + }, + } + + data, err := json.Marshal(records) + if err != nil { + t.Fatalf("failed to marshal current format records: %v", err) + } + csrfFile := filepath.Join(tmpDir, "csrf_tokens.json") + if err := os.WriteFile(csrfFile, data, 0600); err != nil { + t.Fatalf("failed to write current format JSON: %v", err) + } + + store := &CSRFTokenStore{ + tokens: make(map[string][]*CSRFToken), + dataPath: tmpDir, + } + + store.load() + + if !store.ValidateCSRFToken(sessionID, "token-one") { + t.Fatal("first persisted token should validate") + } + if !store.ValidateCSRFToken(sessionID, "token-two") { + t.Fatal("second persisted token should validate") + } + if got := len(store.tokens[sessionKey]); got != 2 { + t.Fatalf("expected 2 retained tokens for session, got %d", got) + } +} + func TestCSRFTokenStore_InitReconfiguresDataPath(t *testing.T) { resetCSRFStoreForTests() t.Cleanup(resetCSRFStoreForTests) diff --git a/internal/api/router_csrf_middleware_test.go b/internal/api/router_csrf_middleware_test.go index 6908d6d6b..759548902 100644 --- a/internal/api/router_csrf_middleware_test.go +++ b/internal/api/router_csrf_middleware_test.go @@ -103,6 +103,46 @@ func TestRouterCSRFAllowsValidToken(t *testing.T) { } } +func TestRouterCSRFRetainsConcurrentReplacementTokens(t *testing.T) { + router, sessionToken := newRouterWithSession(t) + + router.mux.HandleFunc("/api/secure", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + replacementTokens := make([]string, 0, 2) + for _, staleToken := range []string{"stale-token-a", "stale-token-b"} { + req := httptest.NewRequest(http.MethodPost, "/api/secure", nil) + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) + req.Header.Set("X-CSRF-Token", staleToken) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status %d for stale token, got %d (%s)", http.StatusForbidden, rec.Code, rec.Body.String()) + } + replacementToken := rec.Header().Get("X-CSRF-Token") + if replacementToken == "" { + t.Fatal("expected replacement CSRF token header after stale token") + } + replacementTokens = append(replacementTokens, replacementToken) + } + + for i, replacementToken := range replacementTokens { + req := httptest.NewRequest(http.MethodPost, "/api/secure", nil) + req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken}) + req.Header.Set("X-CSRF-Token", replacementToken) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("replacement token %d should remain valid, got status %d (%s)", i, rec.Code, rec.Body.String()) + } + } +} + func TestRouterCSRFBlocksCrossSiteProxyAuthMutation(t *testing.T) { router := newRouterWithProxyAuth(t)