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
+127
View File
@@ -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
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)
}