Fix concurrent CSRF token refresh

This commit is contained in:
rcourtman 2026-04-26 15:27:10 +01:00
parent fee9f7b9da
commit 55e601738f
7 changed files with 311 additions and 62 deletions

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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, &current); 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

View file

@ -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)

View file

@ -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)