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:
@@ -45,29 +45,40 @@ const maxLoginInProgress = 20
|
||||
// live before it is stale and evictable.
|
||||
const loginInProgressWindow = 30 * time.Second
|
||||
|
||||
// maxConcurrentLoginBcrypt bounds how many login requests may run their bcrypt
|
||||
// comparison concurrently (Round 2 Loop A finding 4b). The progressive per-IP
|
||||
// middleware sleeps BEFORE this handler, so without the cap a flood of
|
||||
// throttled login requests could stack an unbounded number of goroutines that
|
||||
// all hit bcrypt the moment their sleeps elapse — a CPU-amplification vector.
|
||||
// Beyond the cap the login is rejected 429 immediately (nothing has been
|
||||
// processed, so nothing leaks).
|
||||
const maxConcurrentLoginBcrypt = 20
|
||||
// maxConcurrentBcrypt bounds how many bcrypt operations may run concurrently
|
||||
// across BOTH /login and /register (Round 2 Loop A finding 4b + Round 2 Loop B
|
||||
// finding 4). The progressive per-IP middleware sleeps BEFORE this handler, so
|
||||
// without the cap a flood of throttled requests could stack an unbounded number
|
||||
// of goroutines that all hit bcrypt the moment their sleeps elapse — a
|
||||
// CPU-amplification vector (a register-botnet also burns CPU on
|
||||
// bcrypt.GenerateFromPassword). Beyond the cap the request is rejected 429
|
||||
// immediately (nothing has been processed, so nothing leaks).
|
||||
//
|
||||
// Round 2 Loop B finding 5b — ACCEPTED BOUNDED-DoS TRADE-OFF: the 20-slot
|
||||
// global bound is shared by login AND register AND, by extension, every
|
||||
// authenticated user. A sustained flood at either endpoint can therefore
|
||||
// starve bcrypt for everyone for up to one request at a time (429 "server
|
||||
// busy"). That is the intended trade-off: 20 genuinely concurrent bcrypt
|
||||
// operations (~20 × ~60ms ≈ 1.2s of wall time) is far more than a single
|
||||
// salon ever produces, and bounding the CPU is the point of the cap.
|
||||
const maxConcurrentBcrypt = 20
|
||||
|
||||
// Login state management
|
||||
var (
|
||||
loginStateMu sync.Mutex
|
||||
loginInProgress = make(map[string]time.Time)
|
||||
// loginBcryptSlots is the counting semaphore backing maxConcurrentLoginBcrypt.
|
||||
loginBcryptSlots = make(chan struct{}, maxConcurrentLoginBcrypt)
|
||||
// authBcryptSlots is the counting semaphore backing maxConcurrentBcrypt,
|
||||
// shared by LoginHandler and RegisterHandler.
|
||||
authBcryptSlots = make(chan struct{}, maxConcurrentBcrypt)
|
||||
)
|
||||
|
||||
// acquireLoginBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
|
||||
// means the handler must respond 429.
|
||||
func acquireLoginBcryptSlot() (release func(), ok bool) {
|
||||
// acquireBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
|
||||
// means the handler must respond 429. Shared by login and register so the
|
||||
// bcrypt CPU budget is global, not per-endpoint.
|
||||
func acquireBcryptSlot() (release func(), ok bool) {
|
||||
select {
|
||||
case loginBcryptSlots <- struct{}{}:
|
||||
return func() { <-loginBcryptSlots }, true
|
||||
case authBcryptSlots <- struct{}{}:
|
||||
return func() { <-authBcryptSlots }, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
@@ -227,6 +238,21 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||
mw.RespondError(w, http.StatusBadRequest, "password must be at least 6 characters")
|
||||
return
|
||||
}
|
||||
// Round 2 Loop B finding 4: /register previously ran the zxcvbn strength
|
||||
// scoring AND bcrypt.GenerateFromPassword with NO concurrency cap — a
|
||||
// register-botnet could stack unbounded goroutines burning CPU, and each
|
||||
// registered account also fuels the notification-flood and attempt-map
|
||||
// findings (1/3). Share the login bcrypt slot budget (acquireBcryptSlot —
|
||||
// the global 20-slot cap, see maxConcurrentBcrypt): beyond it the
|
||||
// registration is rejected 429 immediately. The slot wraps the expensive
|
||||
// part (zxcvbn + bcrypt) and is released via defer on every path.
|
||||
release, ok := acquireBcryptSlot()
|
||||
if !ok {
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||||
return
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Server-side password strength check using the same @zxcvbn-ts/core as the frontend
|
||||
// via goja (ExecJS-style). Guarantees exact parity with frontend scoring.
|
||||
// Skipped when GO_TESTING=1 (dev/test environments) to allow weaker passwords.
|
||||
@@ -499,7 +525,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// released immediately after the compare — bcrypt is the expensive,
|
||||
// amplifier-prone part; the DB work below is cheap. The deferred delete
|
||||
// above releases this user's in-flight slot on every path.
|
||||
release, ok := acquireLoginBcryptSlot()
|
||||
release, ok := acquireBcryptSlot()
|
||||
if !ok {
|
||||
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user