fix: round-2 loop-A fresh review (503c326 baseline) — B1 replay cap, A6 discount record, 2FA reissue+cooldown, notification flood, lockout saturation, VAT/refund-status consolidation

Round 2 Loop A fresh money/security/dup-mod review. 23 findings fixed:

MONEY:
- CRITICAL: B1 duplicate auto-refund gains an attempt cap (b1_attempts col, cap 3) —
  a rejected auto-refund no longer re-replays the expired key every sweep run
  (which minted a stacking unauthorized charge each time); FAILED-webhook
  demotion respects the cap; never re-replay a key whose B1 refund failed
- HIGH: A6 deposit_covered_by_discount skip path now APPLIES the eligible
  campaign discount rows immediately (capped) instead of skipping with no
  discount recorded — no more promised-discount-not-recorded overcharge
- MEDIUM: 2FA code burned by the SAVE gate is re-issued on failed
  new-card+save_card charges (re-issue guard now covers req.SaveCard)
- LOW: GetBookingPaymentSummary excludes tip rows from paidAmount (remaining
  now matches the authoritative tip-excluded balance)

SECURITY:
- MEDIUM: unacknowledged CRITICAL admin-notification flood capped (global cap
  on critical_payment_log + refresh_token_reuse rows)
- MEDIUM: 2FA reissue no longer bypasses the mint cooldown (Check no longer
  clears LastMintAt on gate-verify; cleared on terminal charge success)
- MEDIUM: twofa.StateFor map-saturation returns a shared permanently-locked
  state instead of a fresh 5-guess budget per request
- MEDIUM: ProgressiveRateLimit rejects 429 past maxProgressiveSleepDelayMs
  instead of sleeping unboundedly; login bcrypt concurrency semaphore added
- LOW: loginInProgress 409->429; webhook key-set/URL-unset startup check;
  email-verification per-user attempt counter

DUP/MOD:
- formatCurrency single source (frontend format.ts, 7 files consolidated);
  SquareRefundStatusToLocal single source (errors.go, all sites); admin
  audit-log helper dedup; SCA retry model unified (proactive on all 6
  surfaces); buyDailyTotal/daily-cap mirror via backend; lock TTL from
  backend; generateUUID at all card-form sites; magic numbers named
  (defaultPostgresHost, epsilon, fee constants); admin CASH + gift-card
  terminal charges now audited; DAV_SKIP_INIT documented in manuals

Verified: 26/26 dev + 24/24 prod (GO_TESTING=1, the 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 4e64e32f09
commit 3866cc5963
36 changed files with 2032 additions and 719 deletions
+168 -15
View File
@@ -32,28 +32,143 @@ import (
"golang.org/x/text/language"
)
// maxLoginInProgress caps the loginInProgress map (Round 2 Loop A finding 5):
// at most this many logins may be mid-flight at once before the next is
// rejected 429. Stale entries are evicted before the cap is consulted (see
// evictStaleLoginEntriesLocked), so a single attacker holding N fake entries
// cannot permanently exhaust the global budget — only genuinely concurrent
// logins occupy it, and each entry self-releases via the handler's deferred
// delete.
const maxLoginInProgress = 20
// loginInProgressWindow is how long a loginInProgress entry is considered
// 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
// 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)
)
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than 30 seconds.
// Called by the centralised jobs scheduler.
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
loginStateMu.Lock()
defer loginStateMu.Unlock()
now := clock.Now()
// acquireLoginBcryptSlot tries to reserve a concurrent bcrypt slot. ok=false
// means the handler must respond 429.
func acquireLoginBcryptSlot() (release func(), ok bool) {
select {
case loginBcryptSlots <- struct{}{}:
return func() { <-loginBcryptSlots }, true
default:
return nil, false
}
}
// evictStaleLoginEntriesLocked removes loginInProgress entries older than
// loginInProgressWindow. Caller must hold loginStateMu.
func evictStaleLoginEntriesLocked(now time.Time) {
for userID, startedAt := range loginInProgress {
if now.Sub(startedAt) > 30*time.Second {
if now.Sub(startedAt) > loginInProgressWindow {
delete(loginInProgress, userID)
}
}
}
// CleanupStaleLoginEntries removes stuck loginInProgress entries older than
// loginInProgressWindow. Called by the centralised jobs scheduler.
func CleanupStaleLoginEntries(ctx context.Context) (int, error) {
loginStateMu.Lock()
defer loginStateMu.Unlock()
evictStaleLoginEntriesLocked(clock.Now())
return 0, nil
}
// Email-verification attempt budget (Round 2 Loop A finding 8): POST
// /verify/check had no per-user attempt counter, so a client holding a code
// could fail it indefinitely and the endpoint doubled as an unbounded guessing
// oracle. Mirror the 2FA attempt pattern: an in-memory map keys a 5-attempt
// budget per submitted code. The code is the only identifier a wrong guess
// carries, and every code is user-scoped (one code belongs to exactly one
// user), so the budget is effectively per-user-per-code — a distinct user can
// never drain another's budget for the same code. A successful verify clears
// the entry; the 5th failed attempt exhausts the budget (429). The map is
// bounded and stale entries are evicted, so a flood of random guesses cannot
// grow it without bound.
const (
emailVerifyMaxAttempts = 5
emailVerifyAttemptWindow = 30 * time.Minute
emailVerifyMaxTrackedCodes = 10_000
)
type emailVerifyAttempt struct {
count int
lastAt time.Time
}
var (
emailVerifyMu sync.Mutex
emailVerifyAttempts = make(map[string]emailVerifyAttempt)
)
// emailVerifyAttemptsExhausted reports whether the submitted code's attempt
// budget is already spent, rejecting the request before any DB work.
func emailVerifyAttemptsExhausted(code string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
a, ok := emailVerifyAttempts[code]
return ok && a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptFailed registers one failed verification attempt for the
// submitted code and reports whether the budget for that code is now exhausted
// (the handler should respond 429).
func emailVerifyAttemptFailed(code string) bool {
emailVerifyMu.Lock()
defer emailVerifyMu.Unlock()
evictStaleEmailVerifyAttemptsLocked()
now := clock.Now()
a := emailVerifyAttempts[code]
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
a.count = 0
}
a.count++
a.lastAt = now
emailVerifyAttempts[code] = a
return a.count >= emailVerifyMaxAttempts
}
// emailVerifyAttemptsClear drops the budget for a code after a successful
// verify (the code is consumed; the entry would only leak stale state).
func emailVerifyAttemptsClear(code string) {
emailVerifyMu.Lock()
delete(emailVerifyAttempts, code)
emailVerifyMu.Unlock()
}
// evictStaleEmailVerifyAttemptsLocked bounds the attempts map. Caller must
// hold emailVerifyMu.
func evictStaleEmailVerifyAttemptsLocked() {
if len(emailVerifyAttempts) < emailVerifyMaxTrackedCodes {
return
}
now := clock.Now()
for k, a := range emailVerifyAttempts {
if now.Sub(a.lastAt) > emailVerifyAttemptWindow {
delete(emailVerifyAttempts, k)
}
}
}
type RegisterRequest struct {
FirstName string `json:"firstName" validate:"required,min=1,max=50"`
LastName string `json:"lastName" validate:"required,min=1,max=50"`
@@ -349,11 +464,19 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Check if user is already logging in
// Per-user in-flight slot (finding 5): the same account cannot have two
// concurrent login flows. A re-entry inside the slot window is rejected 429
// (not 409 — no conflict with a finished attempt, and a Conflict response
// would leak that a login for this account is mid-flight). Stale entries are
// evicted before the cap check so a single attacker holding N fake entries
// cannot exhaust the global budget: at most maxLoginInProgress genuinely
// concurrent logins occupy the map, each self-releasing via the deferred
// delete below.
loginStateMu.Lock()
if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second {
evictStaleLoginEntriesLocked(clock.Now())
if t, ok := loginInProgress[userID]; ok && clock.Now().Sub(t) < loginInProgressWindow {
loginStateMu.Unlock()
mw.RespondError(w, http.StatusConflict, "login already in progress")
mw.RespondError(w, http.StatusTooManyRequests, "login already in progress")
return
}
// Cap the map size - drop new request if at capacity
@@ -372,8 +495,19 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
loginStateMu.Unlock()
}()
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
// Verify password under the concurrency cap (finding 4b). The slot is
// 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()
if !ok {
mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later")
return
}
passwordOK := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)) == nil
release()
if !passwordOK {
// Increment failed attempts in DB with progressive lockout
var newFailed int
var newLockedUntil *time.Time
@@ -701,6 +835,13 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Finding 8: a spent attempt budget rejects before any DB work — the code
// can no longer be guessed against.
if emailVerifyAttemptsExhausted(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
var userID string
var purpose string
var expiresAt time.Time
@@ -718,16 +859,25 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
).Scan(&checkUsedAt)
if checkErr != nil {
// Code doesn't exist at all
// Code doesn't exist at all — a guess. Count it against the
// code's attempt budget.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
// Code exists but was already used
// Code exists but was already used — a definite state, not a guess.
if checkUsedAt != nil {
http.Error(w, "code already used", http.StatusForbidden)
return
}
// Code exists but expired
// Code exists but expired — count it against the budget too.
if emailVerifyAttemptFailed(code) {
http.Error(w, "too many attempts. request a new code.", http.StatusTooManyRequests)
return
}
http.Error(w, "invalid or expired code", http.StatusBadRequest)
return
}
@@ -776,6 +926,9 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Finding 8: a successful verify clears the code's attempt budget.
emailVerifyAttemptsClear(code)
if err := json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}