fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks
Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: - CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive 402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with a different body, so it is now AMBIGUOUS (503): the frontend keeps the same idempotency key, the pending row stays rescuable by the sweep (which already treated it as ambiguous), and the frontend no longer regenerates the key into a possible double charge. SCA verification-required codes remain definitive 402. - HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification (insertCriticalPaymentNotification) when issuance is refused (missing pepper / unavailable delivery) instead of silently stranding the customer; documented that a pepper CHANGE invalidates all pending codes. - MEDIUM: family-alive cache invalidation crash window documented (invalidate-after- commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it). - Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal mappings verified, reissue fresh-only semantics confirmed at all 5 call sites. Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
This commit is contained in:
+46
-6
@@ -145,6 +145,18 @@ func IsJTIRevoked(ctx context.Context, jti string) bool {
|
||||
// bound access tokens die immediately when theft is detected (HIGH 1).
|
||||
const familyAliveCacheTTL = 30 * time.Second
|
||||
|
||||
// familyAliveRecheckGrace is how close a cached family-alive verdict must be to
|
||||
// its TTL before verifyFamilyAlive re-validates it against the DB instead of
|
||||
// trusting the cache (Loop B finding 3). The daily refresh-token cleanup
|
||||
// captures the affected families, DELETEs them, commits, and then invalidates
|
||||
// the in-memory cache — a crash between the DELETE commit and the
|
||||
// invalidation leaves the cache warm for up to familyAliveCacheTTL, accepting a
|
||||
// bound access token after its family was killed. Re-validating an ALIVE
|
||||
// verdict within this grace of its expiry bounds that residual window to the
|
||||
// grace itself; a small grace (5s of a 30s TTL) preserves the MEDIUM-1
|
||||
// query-amplification win — only near-expiry lookups re-query.
|
||||
const familyAliveRecheckGrace = 5 * time.Second
|
||||
|
||||
// familyAliveCacheMaxEntries bounds the in-memory map so a flood of distinct
|
||||
// family ids cannot grow it without bound.
|
||||
const familyAliveCacheMaxEntries = 10_000
|
||||
@@ -172,17 +184,32 @@ func init() {
|
||||
// familyAliveLookup returns a cached verdict for a family key and whether it is
|
||||
// still fresh, evicting expired entries opportunistically.
|
||||
func familyAliveLookup(key string) (alive bool, ok bool) {
|
||||
e, ok := familyAliveLookupEntry(key)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return e.alive, true
|
||||
}
|
||||
|
||||
// familyAliveLookupEntry returns the cached verdict entry (with its expiry) for
|
||||
// a family key and whether it is still fresh, evicting expired entries
|
||||
// opportunistically. Unlike familyAliveLookup it hands the caller the entry so
|
||||
// verifyFamilyAlive can re-validate a near-expiry ALIVE verdict against the DB
|
||||
// (Loop B finding 3 — the residual crash window of the refresh-token cleanup's
|
||||
// post-commit cache invalidation); familyAliveLookup stays as the simple
|
||||
// (alive, ok) accessor used by the tests.
|
||||
func familyAliveLookupEntry(key string) (familyAliveCacheEntry, bool) {
|
||||
familyAliveCache.mu.Lock()
|
||||
defer familyAliveCache.mu.Unlock()
|
||||
e, ok := familyAliveCache.m[key]
|
||||
if !ok {
|
||||
return false, false
|
||||
return familyAliveCacheEntry{}, false
|
||||
}
|
||||
if clock.Now().After(e.expires) {
|
||||
delete(familyAliveCache.m, key)
|
||||
return false, false
|
||||
return familyAliveCacheEntry{}, false
|
||||
}
|
||||
return e.alive, true
|
||||
return e, true
|
||||
}
|
||||
|
||||
// familyAliveStore records a DB-confirmed verdict, evicting expired entries
|
||||
@@ -398,6 +425,15 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
|
||||
// IsJTIRevoked — because genuine theft is already handled by the family kill in
|
||||
// VerifyRefreshToken's reuse branch, and a transient DB error must not turn
|
||||
// into a total 401 outage for every authenticated request.
|
||||
//
|
||||
// Loop B finding 3 (crash-safety residual window): an ALIVE verdict within
|
||||
// familyAliveRecheckGrace of its TTL is re-validated against the DB. The daily
|
||||
// refresh-token cleanup (handlers/scheduling/scheduled-cleanup.go
|
||||
// CleanupExpiredRefreshTokens) invalidates the family-alive cache AFTER its
|
||||
// DELETE commits, so a crash between the commit and the invalidation leaves the
|
||||
// cache warm for the rest of the TTL — a family killed by that missed
|
||||
// invalidation would otherwise keep admitting its bound access tokens. The
|
||||
// near-expiry re-check closes the gap to the grace window.
|
||||
func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string) error {
|
||||
var familyVal any
|
||||
if err := token.Get(accessTokenFamilyClaim, &familyVal); err != nil {
|
||||
@@ -411,11 +447,15 @@ func verifyFamilyAlive(ctx context.Context, token jwtClaimGetter, userID string)
|
||||
return nil
|
||||
}
|
||||
key := familyID + "|" + userID
|
||||
if alive, cached := familyAliveLookup(key); cached {
|
||||
if !alive {
|
||||
if e, cached := familyAliveLookupEntry(key); cached {
|
||||
if !e.alive {
|
||||
return fmt.Errorf("token revoked")
|
||||
}
|
||||
return nil
|
||||
// A fresh verdict is trusted (the query-amplification win); only a
|
||||
// near-expiry ALIVE verdict falls through to the DB re-check below.
|
||||
if clock.Now().Before(e.expires.Add(-familyAliveRecheckGrace)) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var exists bool
|
||||
err := db.Conn.QueryRow(ctx,
|
||||
|
||||
Reference in New Issue
Block a user