mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Fix concurrent CSRF token refresh
This commit is contained in:
parent
fee9f7b9da
commit
55e601738f
7 changed files with 311 additions and 62 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue