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:
@@ -1665,6 +1665,133 @@ func TestLoginInProgress_Cap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginInProgress_SameUserReentry_429 pins finding 5: a second login for
|
||||
// the same account while one is mid-flight is rejected 429 (previously 409 — a
|
||||
// Conflict response leaks that a login is in progress for this account and is
|
||||
// semantically wrong for "try again in a moment").
|
||||
func TestLoginInProgress_SameUserReentry_429(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "inprogress@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
// Simulate a login already in flight for this account.
|
||||
loginStateMu.Lock()
|
||||
loginInProgress[userID] = clock.Now()
|
||||
loginStateMu.Unlock()
|
||||
defer func() {
|
||||
loginStateMu.Lock()
|
||||
delete(loginInProgress, userID)
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "inprogress@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 for a re-entry into an in-progress login, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginInProgress_StaleEntriesEvictedBeforeCap pins finding 5b: stale
|
||||
// loginInProgress entries are evicted BEFORE the map cap is consulted, so a
|
||||
// single attacker holding many fake (stale) entries can no longer trip the
|
||||
// global "server busy" 429 for legitimate users — only genuinely concurrent
|
||||
// in-flight logins occupy the budget.
|
||||
func TestLoginInProgress_StaleEntriesEvictedBeforeCap(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(LoginHandler)
|
||||
|
||||
userID, err := fixtures.CreateTestUserWithEmail(tx, "evict-before-cap@test.com", "verified_email")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
|
||||
// Fill the map to the cap with STALE entries (older than the 30s window).
|
||||
loginStateMu.Lock()
|
||||
for i := 0; i < maxLoginInProgress; i++ {
|
||||
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = clock.Now().Add(-loginInProgressWindow - time.Second)
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
defer func() {
|
||||
loginStateMu.Lock()
|
||||
for i := 0; i < maxLoginInProgress; i++ {
|
||||
delete(loginInProgress, fmt.Sprintf("stale-user-%d", i))
|
||||
}
|
||||
loginStateMu.Unlock()
|
||||
}()
|
||||
|
||||
body := LoginRequest{
|
||||
Email: "evict-before-cap@test.com",
|
||||
Password: "testpassword123",
|
||||
}
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/login", body, ctx)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
t.Error("stale entries must be evicted before the cap check — a legitimate login must not get the global 429")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCheck_AttemptBudget_LocksOutAfterFive verifies finding 8: POST
|
||||
// /verify/check now bounds guesses per submitted code — the 6th failed attempt
|
||||
// for a code is rejected 429, mirroring the 2FA attempt pattern — and a
|
||||
// successful verify clears the budget.
|
||||
func TestVerifyCheck_AttemptBudget_LocksOutAfterFive(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
handler := http.HandlerFunc(VerifyCodeHandler)
|
||||
|
||||
// Create a user + a real code so the success path is exercised.
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
defer fixtures.DeleteUser(tx, userID)
|
||||
var realCode string
|
||||
expiresAt := clock.Now().Add(24 * time.Hour)
|
||||
if err := tx.QueryRow(ctx,
|
||||
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
|
||||
userID, expiresAt).Scan(&realCode); err != nil {
|
||||
t.Fatalf("failed to create verification code: %v", err)
|
||||
}
|
||||
defer tx.Exec(ctx, "DELETE FROM verification_codes WHERE user_id = $1", userID)
|
||||
|
||||
// 4 wrong guesses for a code that does not exist → 400 each (the 2FA
|
||||
// pattern: the 5th failure is the lockout).
|
||||
guess := "000000000000"
|
||||
for i := 0; i < 4; i++ {
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("wrong guess %d: expected 400, got %d. body: %s", i+1, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// The 5th failed attempt exhausts the budget → 429.
|
||||
w := testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 on the 5th failed attempt for the same code, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
// A further attempt is rejected before any DB work.
|
||||
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: guess}, ctx)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("expected 429 for a spent budget, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// A DIFFERENT code (the real one) is unaffected by the spent budget and
|
||||
// verifies successfully — budgets are per code.
|
||||
w = testutils.MakeRequestNoAuth(handler, "POST", "/api/verify/check", VerifyCodeRequest{Code: realCode}, ctx)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("a valid code must still verify after another code's budget was spent, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUKPhoneNumber Security Tests
|
||||
//
|
||||
// These tests verify that ValidateUKPhoneNumber rejects or sanitises
|
||||
|
||||
+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