fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore

Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul:

MONEY:
- HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges
- HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining
- MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row
- MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking)
- MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal
- MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited
- MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency

SECURITY:
- HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts
- MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records
- MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded
- MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep)
- MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert
- LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed

DUP/MOD:
- Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named

Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 3866cc5963
commit 4e398a7a2b
31 changed files with 1703 additions and 342 deletions
+36 -14
View File
@@ -652,12 +652,14 @@ func TestProgressiveRateLimiter_DelayEscalatesWithSustainedRate(t *testing.T) {
}
}
// TestProgressiveRateLimit_RejectsBeyondSleepCap pins finding 4a: once the
// computed delay exceeds maxProgressiveSleepDelayMs (2s), the middleware
// rejects the request 429 immediately instead of sleeping a goroutine for
// 5-10s (a per-client goroutine-parking amplifier in front of bcrypt). The
// small progressive tiers (500ms / 2s) still sleep.
func TestProgressiveRateLimit_RejectsBeyondSleepCap(t *testing.T) {
// TestProgressiveRateLimit_RejectsOnlyTopTier pins finding 4a + Round 2 Loop B
// finding 5: ONLY the top 10s abuse tier rejects the request 429 immediately
// instead of sleeping a goroutine (a per-client goroutine-parking amplifier in
// front of bcrypt); the 500ms / 2s / 5s tiers keep sleeping (backoff). Before
// finding 5 every tier past 2s hard-rejected, which under a shared NAT /
// TRUST_PROXY_HEADERS=false deployment locked out the whole surface behind one
// abusive client.
func TestProgressiveRateLimit_RejectsOnlyTopTier(t *testing.T) {
globalProgressiveLimiter.mu.Lock()
saved := globalProgressiveLimiter.requests
globalProgressiveLimiter.requests = make(map[string]*ipProgressiveState)
@@ -668,28 +670,48 @@ func TestProgressiveRateLimit_RejectsBeyondSleepCap(t *testing.T) {
globalProgressiveLimiter.mu.Unlock()
})
// Seed the 10s abuse tier (sustained > 300) for this IP.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.99", 31, 320)
// 5s tier (sustained 201-300): sleeps (backoff), never rejects — the NAT /
// proxy-collapse case where a moderate sustained rate must not hard-lock
// the whole shared surface.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.98", 31, 250)
nextCalled := false
handler := ProgressiveRateLimit(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "198.51.100.99:1234"
req.RemoteAddr = "198.51.100.98:1234"
w := httptest.NewRecorder()
start := time.Now()
handler.ServeHTTP(w, req)
if !nextCalled {
t.Error("the 5s tier must sleep (backoff), not reject — next handler must be called")
}
if w.Code != http.StatusOK {
t.Errorf("expected the 5s tier to sleep and pass through, got %d", w.Code)
}
if got := w.Header().Get("X-RateLimit-Delay"); got != "5000" {
t.Errorf("expected X-RateLimit-Delay=5000 for the 5s tier, got %q", got)
}
if elapsed := time.Since(start); elapsed < 4*time.Second {
t.Errorf("the 5s tier must actually sleep (took %s)", elapsed)
}
// 10s abuse tier (sustained > 300): rejects 429 immediately.
seedProgressiveTimestamps(t, globalProgressiveLimiter, "198.51.100.99", 31, 320)
nextCalled = false
req = httptest.NewRequest(http.MethodPost, "/login", nil)
req.RemoteAddr = "198.51.100.99:1234"
w = httptest.NewRecorder()
start = time.Now()
handler.ServeHTTP(w, req)
if nextCalled {
t.Error("next handler must NOT be called when the delay exceeds the sleep cap")
t.Error("next handler must NOT be called at the 10s abuse tier")
}
if w.Code != http.StatusTooManyRequests {
t.Errorf("expected 429, got %d", w.Code)
t.Errorf("expected 429 at the 10s abuse tier, got %d", w.Code)
}
if elapsed := time.Since(start); elapsed >= 5*time.Second {
t.Errorf("the 5-10s tiers must reject immediately, not sleep (took %s)", elapsed)
t.Errorf("the 10s tier must reject immediately, not sleep (took %s)", elapsed)
}
}