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:
+168
-15
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user