fix: review round 7 — fresh-eyes audit fixes (6 agents) + full test suites for every backend change
Fresh-eyes review round with 6 independent agents (money-safety, concurrency, Square wire parity, security, frontend flow, testing-gaps). Every finding was independently verified against the code before fixing. All backend changes now carry full test suites (10+ new tests, each verified to FAIL without its guard). All 20 packages green, race detector clean. Money-safety: - Gift-card purchase refunds no longer create money: manual refunds of a no-booking (gift-card purchase) payment are rejected with a clear message in the direct handler AND never re-issued by the sweep-resume path (processManualPaymentGroup skips them; reconcile-then-fail, no re-issue). - BuyGiftCard no-client-key fallback: derived deterministically under the advisory lock (pending-row reuse fixes lost-response double-charge; completed-row sequence advance preserves distinct-purchase collapse fix). - Terminal completion is never unrecorded: activeTerminalCheckoutID now calls recordUntrackedTerminalPayment when a provisional (tmp-) checkout is found COMPLETED at Square (previously only marked the row COMPLETED — a lost poll left the payment invisible and unrefundable). - Sweep: provisional tmp- checkout rows are resolved against Square first (COMPLETED → record; live → keep guard; NOT_FOUND/CANCELED → fail; ambiguous → leave pending) instead of blind-failing a possibly-live checkout. recordUntrackedTerminalPayment re-checks the booking status (FOR UPDATE) and refuses to record on a cancelled booking, inserting a critical_payment_log admin notification instead. Till-sale post-charge UPDATE now requires status='pending' (no resurrection of a clawed-back sale). Frontend (Svelte 5): - UserPaymentModal keeps CardSelection mounted through processing (bind:this ref + Square iframe survive the loyalty/tokenize awaits) — new-card payments work again. - BookingFlow clears the cached nonce/verification pair on any failure (retry re-tokenizes fresh; idempotency key retained for dedup); 409 'already paid' refetches the booking and reconciles depositPaid so the confirmation gate opens; Back button disabled during processing. - Synchronous double-submit guards on buyGiftCard/redeemGiftCard/submitTip. Square wire parity (mock vs real): - processing_fee sign unified (negated at paymentFromSquare; mock agrees). - SimulateSourceUsed (SOURCE_USED, 400) matches real CreateCard. - GetCardsOnFile excludes disabled cards (matches ListCards). - ForcePaymentStatus toggle + tests prove the charge path can't be status-blind. - CreateCheckout rejects empty device_id (env fallback SQUARE_TERMINAL_DEVICE_ID); completed terminal checkout's payment resolvable by id. Security: - 2FA attempt-map data race fixed: lastAt is atomic.Int64 (nanos) — eviction scan reads race-free; concurrent verify+evict tests under -race. - Backend refuses to start on weak/placeholder JWT_SECRET_KEY (<32 chars or known public placeholders) with openssl rand -hex 32 guidance. - Dockerfile no longer COPYs .env (secrets injected via compose env_file). - SabreDAV requires DAV_ADMIN_PASSWORD (no admin/admin default); compose fails at config time when missing. Testing gaps closed (each verified to FAIL without its guard): - refunded-dedup 409 (CreateBookingPayment), keyed sweep past-retention blind-fail, reconcile status-switch (CANCELED/FAILED/APPROVED/PENDING/unknown in both by-key and by-id paths), resolveChargeSource Square-failure branches, structured 500 / CARD_DECLINED / cancelled-context E2E (row stays pending), deriveBookingPaymentIdempotencyKey >45-char truncation, webhook findPaymentByDisputeID fallback, clawbackOneTillSale non-gift-card branch, dispute.evidence / terminal.checkout dispatch. Infra: - local-dev-2.sh fails loudly on port-5432 squatters / docker compose failures (previously died silently under ERR_EXIT with hidden output). - Test harness defaults SQUARE_TERMINAL_DEVICE_ID; money_safety_fixes_test.go gained the missing build tag. Verification: go test -tags test,dev -count=1 -parallel 8 ./... (20/20 ok), -race clean on 2FA + payments money paths, go build ./... + -tags dev, go vet clean, svelte-check 0 errors, env-docs gate OK (36 vars), docker compose config valid.
This commit is contained in:
@@ -126,24 +126,43 @@ var twoFAMaxTrackedAttempts = 10_000
|
||||
|
||||
// twoFAAttemptState tracks consecutive failed verify attempts for one user. The
|
||||
// per-user mutex serializes the whole verify critical section so concurrent
|
||||
// attempts from the same user cannot race the limit check. count is atomic so
|
||||
// the map eviction path can read it without taking the per-user mutex (lock
|
||||
// ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then takes mapMu).
|
||||
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown).
|
||||
// attempts from the same user cannot race the limit check. count and lastAt are
|
||||
// atomic so the map eviction path can read them without taking the per-user
|
||||
// mutex (lock ordering forbids mapMu→st.mu: checkTwoFACode holds st.mu then
|
||||
// takes mapMu). lastAt is stored as nanoseconds since the Unix epoch so the
|
||||
// eviction scan and lockedOut read it race-free even on 32-bit platforms — a
|
||||
// plain time.Time read/write pair there could tear the 8-byte timestamp and
|
||||
// reset or extend the lockout window.
|
||||
// lastMintAt is the disable-flow mint cooldown stamp (see twoFAMintCooldown);
|
||||
// it is only ever touched under st.mu.
|
||||
type twoFAAttemptState struct {
|
||||
mu sync.Mutex
|
||||
count atomic.Int32
|
||||
lastAt time.Time
|
||||
lastAt atomic.Int64
|
||||
lastMintAt time.Time
|
||||
}
|
||||
|
||||
// lastActive returns the state's last-activity timestamp (nanoseconds since the
|
||||
// Unix epoch, UTC). Reads are atomic so the map eviction scan can call it while
|
||||
// holding only mapMu.
|
||||
func (st *twoFAAttemptState) lastActive() time.Time {
|
||||
return time.Unix(0, st.lastAt.Load()).UTC()
|
||||
}
|
||||
|
||||
// setLastActive records a last-activity timestamp. Writes happen under st.mu
|
||||
// (checkTwoFACode) while the eviction scan reads under mapMu only — the atomic
|
||||
// store makes both race-free.
|
||||
func (st *twoFAAttemptState) setLastActive(t time.Time) {
|
||||
st.lastAt.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
// lockedOut reports whether the state is inside its lockout window: the attempt
|
||||
// counter has reached the cap and the window has not yet elapsed. Such a record
|
||||
// is the rate limit's source of truth for its user and must never be evicted
|
||||
// while in-window — evicting it would silently reset the counter and grant a
|
||||
// fresh guessing budget.
|
||||
func (st *twoFAAttemptState) lockedOut(now time.Time) bool {
|
||||
return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastAt) <= twoFAAttemptWindow
|
||||
return st.count.Load() >= twoFAMaxAttempts && now.Sub(st.lastActive()) <= twoFAAttemptWindow
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -168,7 +187,7 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
|
||||
var oldestID string
|
||||
var oldestAt time.Time
|
||||
for id, st := range twoFAAttemptMap {
|
||||
if now.Sub(st.lastAt) > twoFAAttemptWindow {
|
||||
if now.Sub(st.lastActive()) > twoFAAttemptWindow {
|
||||
// Idle/expired — its counter has already lapsed; safe to evict.
|
||||
delete(twoFAAttemptMap, id)
|
||||
continue
|
||||
@@ -178,8 +197,8 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
|
||||
// for this user. Never evict (finding-e fix).
|
||||
continue
|
||||
}
|
||||
if oldestID == "" || st.lastAt.Before(oldestAt) {
|
||||
oldestID, oldestAt = id, st.lastAt
|
||||
if at := st.lastActive(); oldestID == "" || at.Before(oldestAt) {
|
||||
oldestID, oldestAt = id, at
|
||||
}
|
||||
}
|
||||
if len(twoFAAttemptMap) >= twoFAMaxTrackedAttempts && oldestID != "" {
|
||||
@@ -190,13 +209,16 @@ func twoFAAttemptStateFor(userID string) *twoFAAttemptState {
|
||||
// (that would reset its rate limit) and do not grow past the cap:
|
||||
// return a transient, untracked state so THIS request still
|
||||
// proceeds under a fresh budget.
|
||||
return &twoFAAttemptState{lastAt: now}
|
||||
st := &twoFAAttemptState{}
|
||||
st.setLastActive(now)
|
||||
return st
|
||||
}
|
||||
}
|
||||
|
||||
st := twoFAAttemptMap[userID]
|
||||
if st == nil {
|
||||
st = &twoFAAttemptState{lastAt: now}
|
||||
st = &twoFAAttemptState{}
|
||||
st.setLastActive(now)
|
||||
twoFAAttemptMap[userID] = st
|
||||
}
|
||||
return st
|
||||
@@ -394,9 +416,9 @@ const (
|
||||
// (callers return 500); a lockout's pending-code invalidation failure is logged
|
||||
// here and still reported as a lockout.
|
||||
func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCode string) (twoFACodeCheckResult, error) {
|
||||
if now := clock.Now(); now.Sub(st.lastAt) > twoFAAttemptWindow {
|
||||
if now := clock.Now(); now.Sub(st.lastActive()) > twoFAAttemptWindow {
|
||||
st.count.Store(0)
|
||||
st.lastAt = now
|
||||
st.setLastActive(now)
|
||||
}
|
||||
if st.count.Load() >= twoFAMaxAttempts {
|
||||
return twoFACodeLockedOut, nil
|
||||
@@ -422,7 +444,7 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
|
||||
match, legacy := verifyTwoFACodeHash(reqCode, pendingHash.String)
|
||||
if !match {
|
||||
st.count.Add(1)
|
||||
st.lastAt = clock.Now()
|
||||
st.setLastActive(clock.Now())
|
||||
if st.count.Load() >= twoFAMaxAttempts {
|
||||
// Lockout reached: destroy the pending code so a stolen digest
|
||||
// cannot be replayed against a fresh guessing loop.
|
||||
@@ -453,7 +475,7 @@ func checkTwoFACode(r *http.Request, userID string, st *twoFAAttemptState, reqCo
|
||||
// Success: clear the attempt counter (and any disable-flow mint cooldown)
|
||||
// before the caller performs its action.
|
||||
st.count.Store(0)
|
||||
st.lastAt = clock.Now()
|
||||
st.setLastActive(clock.Now())
|
||||
st.lastMintAt = time.Time{}
|
||||
twoFAResetAttempts(userID)
|
||||
return twoFACodeOK, nil
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -681,7 +682,8 @@ func TestTwoFAPepper_LegacyHashUpgrade_OnSuccessfulVerify(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// checkTwoFACode (the shared verify path) must accept the legacy hash.
|
||||
st := &twoFAAttemptState{lastAt: clock.Now()}
|
||||
st := &twoFAAttemptState{}
|
||||
st.setLastActive(clock.Now())
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(ctx)
|
||||
result, err := checkTwoFACode(req, userID, st, "123456")
|
||||
require.NoError(t, err)
|
||||
@@ -845,9 +847,12 @@ func TestTwoFAAttemptMap_InLockoutRecordNotEvicted(t *testing.T) {
|
||||
|
||||
now := clock.Now()
|
||||
for _, id := range []string{"idle_a", "idle_b", "idle_c"} {
|
||||
twoFAAttemptMap[id] = &twoFAAttemptState{lastAt: now.Add(-time.Minute)}
|
||||
st := &twoFAAttemptState{}
|
||||
st.setLastActive(now.Add(-time.Minute))
|
||||
twoFAAttemptMap[id] = st
|
||||
}
|
||||
victim := &twoFAAttemptState{lastAt: now.Add(-time.Second)}
|
||||
victim := &twoFAAttemptState{}
|
||||
victim.setLastActive(now.Add(-time.Second))
|
||||
victim.count.Store(5)
|
||||
twoFAAttemptMap["victim"] = victim
|
||||
|
||||
@@ -889,7 +894,8 @@ func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) {
|
||||
|
||||
now := clock.Now()
|
||||
for i := 0; i < 3; i++ {
|
||||
st := &twoFAAttemptState{lastAt: now.Add(-time.Second)}
|
||||
st := &twoFAAttemptState{}
|
||||
st.setLastActive(now.Add(-time.Second))
|
||||
st.count.Store(5)
|
||||
twoFAAttemptMap[fmt.Sprintf("locked_%d", i)] = st
|
||||
}
|
||||
@@ -903,3 +909,188 @@ func TestTwoFAAttemptMap_FullOfLockedOut_ReturnsTransient(t *testing.T) {
|
||||
t.Errorf("expected all 3 locked-out records to survive, got %d", len(twoFAAttemptMap))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Attempt-map concurrency (finding-f — lastAt data race)
|
||||
// =============================================================================
|
||||
|
||||
// TestTwoFAAttemptMap_ConcurrentVerifyAndEviction is a -race smoke test for the
|
||||
// attempt-map data race: checkTwoFACode's st.mu-guarded writes to count and
|
||||
// lastAt run concurrently with twoFAAttemptStateFor's mapMu-only eviction scan
|
||||
// reading them. lastAt is an atomic.Int64 (nanoseconds since the epoch), so the
|
||||
// scan and lockedOut read it without st.mu — no mutex inversion (mapMu→st.mu is
|
||||
// forbidden) and no torn 8-byte timestamp. Asserts every concurrent call
|
||||
// completes (no deadlock), pre-pinned locked-out records survive the eviction
|
||||
// pressure, and the map never grows past the cap.
|
||||
func TestTwoFAAttemptMap_ConcurrentVerifyAndEviction(t *testing.T) {
|
||||
twoFAAttemptMapMu.Lock()
|
||||
origMap := twoFAAttemptMap
|
||||
origCap := twoFAMaxTrackedAttempts
|
||||
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
|
||||
twoFAMaxTrackedAttempts = 128
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
twoFAAttemptMapMu.Lock()
|
||||
twoFAAttemptMap = origMap
|
||||
twoFAMaxTrackedAttempts = origCap
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
})
|
||||
|
||||
const verifyWorkers = 6
|
||||
const evictWorkers = 4
|
||||
const iters = 25
|
||||
|
||||
// Pre-pin locked-out victims so we can assert afterwards that in-window
|
||||
// lockout records are never evicted under concurrent pressure.
|
||||
now := clock.Now()
|
||||
victims := make(map[string]*twoFAAttemptState, verifyWorkers)
|
||||
twoFAAttemptMapMu.Lock()
|
||||
for i := 0; i < verifyWorkers; i++ {
|
||||
st := &twoFAAttemptState{}
|
||||
st.setLastActive(now.Add(-time.Second))
|
||||
st.count.Store(twoFAMaxAttempts)
|
||||
id := fmt.Sprintf("victim_%d", i)
|
||||
twoFAAttemptMap[id] = st
|
||||
victims[id] = st
|
||||
}
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Verifiers mirror checkTwoFACode's critical section on shared states: take
|
||||
// st.mu, reset an expired window, bump the counter, stamp lastAt, and read
|
||||
// lockedOut — overlapping the eviction scan's lock-free atomic reads.
|
||||
for w := 0; w < verifyWorkers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for iter := 0; iter < iters; iter++ {
|
||||
st := twoFAAttemptStateFor(fmt.Sprintf("verify_%d_%d", w, iter))
|
||||
st.mu.Lock()
|
||||
if now := clock.Now(); now.Sub(st.lastActive()) > twoFAAttemptWindow {
|
||||
st.count.Store(0)
|
||||
st.setLastActive(now)
|
||||
}
|
||||
_ = st.lockedOut(clock.Now())
|
||||
st.count.Add(1)
|
||||
st.setLastActive(clock.Now())
|
||||
st.mu.Unlock()
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Evictors drive twoFAAttemptStateFor's cap-driven eviction scan, which
|
||||
// reads count + lastAt WITHOUT st.mu — the access pattern under test.
|
||||
for w := 0; w < evictWorkers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for iter := 0; iter < 2000; iter++ {
|
||||
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
twoFAAttemptMapMu.Lock()
|
||||
defer twoFAAttemptMapMu.Unlock()
|
||||
for id, st := range victims {
|
||||
if _, ok := twoFAAttemptMap[id]; !ok {
|
||||
t.Errorf("in-window lockout record %s was evicted under concurrent pressure", id)
|
||||
}
|
||||
if !st.lockedOut(clock.Now()) {
|
||||
t.Errorf("victim %s must still report locked out", id)
|
||||
}
|
||||
}
|
||||
if len(twoFAAttemptMap) > twoFAMaxTrackedAttempts {
|
||||
t.Errorf("map grew past the cap: %d > %d", len(twoFAAttemptMap), twoFAMaxTrackedAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock runs the REAL verify path
|
||||
// concurrently: each goroutine mints its own transaction and user (pgx.Tx is
|
||||
// not concurrency-safe, so per-goroutine tx avoids sharing one), burns the
|
||||
// 5-attempt budget to lockout, and asserts lockedOut afterwards — while other
|
||||
// goroutines hammer the map eviction scan through twoFAAttemptStateFor. The
|
||||
// test completes only if no goroutine deadlocks on mapMu/st.mu.
|
||||
func TestTwoFA_ConcurrentCheckTwoFACode_NoDeadlock(t *testing.T) {
|
||||
twoFAAttemptMapMu.Lock()
|
||||
origMap := twoFAAttemptMap
|
||||
origCap := twoFAMaxTrackedAttempts
|
||||
twoFAAttemptMap = make(map[string]*twoFAAttemptState)
|
||||
twoFAMaxTrackedAttempts = 64
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
twoFAAttemptMapMu.Lock()
|
||||
twoFAAttemptMap = origMap
|
||||
twoFAMaxTrackedAttempts = origCap
|
||||
twoFAAttemptMapMu.Unlock()
|
||||
})
|
||||
|
||||
const workers = 6
|
||||
const iters = 15
|
||||
var wg sync.WaitGroup
|
||||
errCh := make(chan error, workers)
|
||||
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
ctx := context.Background()
|
||||
tx, err := db.Conn.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("worker %d begin: %w", w, err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(context.Background())
|
||||
tctx := db.ContextWithTx(ctx, tx)
|
||||
for iter := 0; iter < iters; iter++ {
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("worker %d iter %d create user: %w", w, iter, err)
|
||||
return
|
||||
}
|
||||
seedPendingTwoFA(t, tctx, tx, userID, "123456")
|
||||
st := twoFAAttemptStateFor(userID)
|
||||
for attempt := 1; attempt <= twoFAMaxAttempts; attempt++ {
|
||||
st.mu.Lock()
|
||||
res, err := checkTwoFACode(httptest.NewRequest(http.MethodPost, "/api/user/2fa/verify", nil).WithContext(tctx), userID, st, "999999")
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("worker %d iter %d check: %w", w, iter, err)
|
||||
return
|
||||
}
|
||||
want := twoFACodeIncorrect
|
||||
if attempt == twoFAMaxAttempts {
|
||||
want = twoFACodeLockedOut
|
||||
}
|
||||
if res != want {
|
||||
errCh <- fmt.Errorf("worker %d iter %d attempt %d: got %v, want %v", w, iter, attempt, res, want)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !st.lockedOut(clock.Now()) {
|
||||
errCh <- fmt.Errorf("worker %d iter %d: must be locked out after %d wrong codes", w, iter, twoFAMaxAttempts)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
// Concurrent map pressure: twoFAAttemptStateFor reads count + lastAt under
|
||||
// mapMu only, racing the workers' st.mu-guarded writes (the old data race).
|
||||
for w := 0; w < 4; w++ {
|
||||
wg.Add(1)
|
||||
go func(w int) {
|
||||
defer wg.Done()
|
||||
for iter := 0; iter < 1000; iter++ {
|
||||
_ = twoFAAttemptStateFor(fmt.Sprintf("flood_%d_%d", w, iter))
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user