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:
+14
-12
@@ -102,14 +102,16 @@ func (prl *ProgressiveRateLimiter) Check(ip string) (delayMs int) {
|
||||
}
|
||||
}
|
||||
|
||||
// maxProgressiveSleepDelayMs is the largest delay the progressive per-IP
|
||||
// limiter still absorbs by sleeping. Beyond it the request is rejected 429
|
||||
// immediately instead (Round 2 Loop A finding 4a): sleeping 5-10s ties up a
|
||||
// goroutine per throttled request while the client keeps hammering, so one
|
||||
// client can stack many sleeping goroutines in front of the bcrypt wall on
|
||||
// /login and /register. The small progressive tiers (500ms / 2s) are
|
||||
// unchanged.
|
||||
const maxProgressiveSleepDelayMs = 2000
|
||||
// progressiveRejectDelayMs is the delay at which the progressive per-IP limiter
|
||||
// stops sleeping and rejects the request 429 immediately (Round 2 Loop B
|
||||
// finding 5). ONLY the TOP abuse tier (10s) rejects: the 500ms / 2s / 5s tiers
|
||||
// keep sleeping (backoff). Previously every tier past 2s hard-rejected, which
|
||||
// under a shared NAT — or any TRUST_PROXY_HEADERS=false deployment where every
|
||||
// client collapses onto the proxy's IP — locked out the whole surface behind
|
||||
// one abusive client. Now a moderate sustained rate still sleeps (throttling
|
||||
// the offender with backoff) instead of hard-rejecting everyone, while the 10s
|
||||
// abuse tier keeps the goroutine-parking amplifier bound.
|
||||
const progressiveRejectDelayMs = 10000
|
||||
|
||||
func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,10 +119,10 @@ func ProgressiveRateLimit(next http.Handler) http.Handler {
|
||||
|
||||
delay := globalProgressiveLimiter.Check(ip)
|
||||
switch {
|
||||
case delay > maxProgressiveSleepDelayMs:
|
||||
// Far past the sustained budget — reject now instead of parking a
|
||||
// goroutine for 5-10s. The rejection is a clean 429; the client
|
||||
// retries after the burst window lapses.
|
||||
case delay >= progressiveRejectDelayMs:
|
||||
// Top abuse tier only — far past the sustained budget, reject now
|
||||
// instead of parking a goroutine for 10s. The rejection is a clean
|
||||
// 429; the client retries after the burst window lapses.
|
||||
RespondJSON(w, http.StatusTooManyRequests, map[string]string{"error": "Rate limit exceeded"})
|
||||
return
|
||||
case delay > 0:
|
||||
|
||||
Reference in New Issue
Block a user