fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// Package adminnotify owns the shared flood cap for the DB-backed admin
|
||||
// notification centre (admin_notifications). That table is the single
|
||||
// operator's ONLY pager for money events, so every site that inserts an
|
||||
// operator-facing alert must bound its unacknowledged queue — a hostile flood
|
||||
// (attacker-registered accounts triggering refresh_token_reuse / reissue-fail /
|
||||
// webhook alerts) must not be able to bury the notification centre under rows
|
||||
// the operator can never work through.
|
||||
//
|
||||
// Coordination contract (Round 2 Loop B finding 1): the cap is applied
|
||||
// atomically at every insert site in this codebase:
|
||||
//
|
||||
// - handlers/payments/twofa.go — the reissue-fail alert (per-issue capped,
|
||||
// see finding 2; NOT globally capped).
|
||||
// - auth/jwt.go VerifyRefreshToken — the 'refresh_token_reuse' alert.
|
||||
// - handlers/webhooks/square.go — the three critical_payment_log inserts
|
||||
// (dispute, booking, unknown-event, orphan-replay).
|
||||
// - handlers/user/account.go InsertSquareErasureCriticalNotification.
|
||||
//
|
||||
// Sites owned by OTHER agents that still need the fold (coordination notes):
|
||||
//
|
||||
// - handlers/payments/sweep.go:1556 insertCriticalPaymentNotification (money
|
||||
// agent) — its INSERT ... SELECT ... WHERE NOT EXISTS is the same
|
||||
// unbounded-across-accounts shape; fold `AND (SELECT COUNT(*) FROM
|
||||
// admin_notifications _an WHERE _an.reason = 'critical_payment_log' AND
|
||||
// _an.acknowledged_at IS NULL) < $N` (N = MaxUnacknowledgedCriticalLogs)
|
||||
// into its WHERE clause.
|
||||
// - handlers/scheduling/time-blockers.go:511 insertSquareCleanupCriticalNotification
|
||||
// and internal/jobs/cleanup.go:333 ScanCriticalPaymentLogs — the same
|
||||
// 'critical_payment_log' insert shape on the GDPR-cleanup and log-scan
|
||||
// paths (owner: the jobs/scheduling agent).
|
||||
// - main.go has NO admin_notifications insert sites (it only mounts the
|
||||
// notification read/ack routes), so nothing to cap there.
|
||||
//
|
||||
// Every site folds the cap INTO its INSERT (a conditional
|
||||
// `INSERT ... SELECT ... WHERE (SELECT COUNT(*) ...) < $cap`) so the
|
||||
// count-then-insert is ATOMIC — closing the TOCTOU where two concurrent
|
||||
// inserts both read a below-cap count and overshoot together (finding 2).
|
||||
package adminnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// MaxUnacknowledgedCriticalLogs is the GLOBAL cap on unacknowledged
|
||||
// admin_notifications rows for one reason (named after the money-critical
|
||||
// reason 'critical_payment_log' that the cap exists to protect). Once the
|
||||
// unacknowledged queue for a reason reaches the cap, further inserts for that
|
||||
// reason are dropped (with a suppression log for operator visibility) until
|
||||
// the operator acknowledges outstanding rows. 100 is far beyond anything a
|
||||
// single salon produces legitimately, so it only ever suppresses an abnormal
|
||||
// flood.
|
||||
const MaxUnacknowledgedCriticalLogs = 100
|
||||
|
||||
// CriticalLogsCapExceeded reports whether the unacknowledged admin-notification
|
||||
// queue for reason has reached MaxUnacknowledgedCriticalLogs. q routes through
|
||||
// the caller's transaction when one is active (the ctx-routed db.Conn proxy, or
|
||||
// an explicit pgx.Tx).
|
||||
//
|
||||
// Best-effort and fail-OPEN: a count error is logged and false is returned, so
|
||||
// a money alert is never dropped because the count query failed (the insert's
|
||||
// own atomic cap condition below still guards the row in that case — the
|
||||
// pre-check only decides whether to log a suppression).
|
||||
func CriticalLogsCapExceeded(ctx context.Context, q db.Querier, reason string) bool {
|
||||
var n int
|
||||
err := q.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM admin_notifications
|
||||
WHERE reason = $1::admin_notification_reason AND acknowledged_at IS NULL
|
||||
`, reason).Scan(&n)
|
||||
if err != nil {
|
||||
log.Printf("adminnotify: failed to count unacknowledged %s admin notifications: %v", reason, err)
|
||||
return false
|
||||
}
|
||||
return n >= MaxUnacknowledgedCriticalLogs
|
||||
}
|
||||
@@ -143,6 +143,27 @@ func (st *AttemptState) SetLastActive(t time.Time) {
|
||||
st.LastAt.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
// SetLastMintAtLocked records the per-user mint-cooldown stamp. The caller
|
||||
// MUST already hold st.Mu (matching how the stamp is read by
|
||||
// twoFAMintThrottled in handlers/user and the payments re-issue cooldown).
|
||||
//
|
||||
// Round 2 Loop B finding 3a: writing to the SHARED saturated state is a
|
||||
// NO-OP. saturatedLockedState is returned by StateFor for EVERY untracked user
|
||||
// once the map is at capacity, so a mint stamped on it would throttle every
|
||||
// untracked user for the whole cooldown (one user's mint blocks everyone for
|
||||
// 60s) and ClearMintCooldownForUser would clear it for all of them. The
|
||||
// singleton's stamp therefore stays permanently zeroed: under saturation,
|
||||
// per-user mints are unthrottled, which is SAFE because a mint never grants a
|
||||
// fresh guessing budget (B11b) and the saturated state is already permanently
|
||||
// locked out for verification. The stamp must also never be written by the
|
||||
// reissue/mint paths when st IS the singleton — the no-op below guarantees it.
|
||||
func (st *AttemptState) SetLastMintAtLocked(t time.Time) {
|
||||
if st == saturatedLockedState {
|
||||
return
|
||||
}
|
||||
st.LastMintAt = t
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -199,9 +220,14 @@ func StateFor(userID string) *AttemptState {
|
||||
delete(Map, id)
|
||||
continue
|
||||
}
|
||||
if st.LockedOut(now) {
|
||||
// Inside its lockout window — the rate limit's source of truth
|
||||
// for this user. Never evict (finding-e fix).
|
||||
if st.LockedOut(now) || st.Count.Load() > 0 {
|
||||
// Round 2 Loop B finding 3b: an in-window record with a
|
||||
// NON-ZERO attempt counter is the rate limit's IN-PROGRESS
|
||||
// state for a genuine user (a locked-out record, or a user
|
||||
// mid-window with failed attempts banked). Evicting it would
|
||||
// silently reset the counter and grant a fresh guessing
|
||||
// budget. Only count==0 in-window records (fresh lookups /
|
||||
// idle mint-cooldown stamps) are evictable.
|
||||
continue
|
||||
}
|
||||
if at := st.LastActive(); oldestID == "" || at.Before(oldestAt) {
|
||||
@@ -212,18 +238,30 @@ func StateFor(userID string) *AttemptState {
|
||||
delete(Map, oldestID)
|
||||
}
|
||||
if len(Map) >= MaxTrackedAttempts {
|
||||
// Every entry is a locked-out in-window record. Do not evict one
|
||||
// (that would reset its rate limit) and do not grow past the cap:
|
||||
// return the SHARED permanently-locked state (finding 3, Round 2
|
||||
// Loop A). Previously a fresh transient state was returned per
|
||||
// call, so every untracked user received a fresh 5-guess budget
|
||||
// per request — silently disabling the brute-force lockout exactly
|
||||
// Every entry is a protected in-window record (locked out, or
|
||||
// carrying an in-progress counter). Do not evict one (that would
|
||||
// reset its rate limit) and do not grow past the cap: return the
|
||||
// SHARED permanently-locked state (finding 3, Round 2 Loop A).
|
||||
// Previously a fresh transient state was returned per call, so
|
||||
// every untracked user received a fresh 5-guess budget per
|
||||
// request — silently disabling the brute-force lockout exactly
|
||||
// under the hostile flood that saturated the map. The shared state
|
||||
// treats every untracked user as locked out instead. It is never
|
||||
// stored in Map (so the eviction scan / ResetAttempts /
|
||||
// DeleteAttempts never touch it) and self-heals: as soon as one of
|
||||
// the real locked-out records lapses out of its window, StateFor
|
||||
// evicts it and normal per-user tracking resumes.
|
||||
//
|
||||
// Round 2 Loop B finding 3c — ACCEPTED RESIDUAL: an attacker can
|
||||
// still fill the map with up to MaxTrackedAttempts (~10k)
|
||||
// in-window locked-out records, forcing every other user into the
|
||||
// shared saturated state. That is a bounded, FAIL-CLOSED outcome:
|
||||
// the fallback is a locked-out-everyone state (brute force
|
||||
// impossible, availability reduced), never a locked-out-nobody one.
|
||||
// Mint cooldowns are unthrottled under saturation (the shared
|
||||
// stamp is zeroed — see SetLastMintAtLocked), which is safe
|
||||
// because a mint grants no guessing budget (B11b). The map drains
|
||||
// as locked-out windows lapse.
|
||||
return saturatedLockedState
|
||||
}
|
||||
}
|
||||
@@ -492,13 +530,26 @@ func ConsumePendingCode(ctx context.Context, q db.Querier, userID string) error
|
||||
}
|
||||
|
||||
// ClearMintCooldownForUser zeroes the user's mint-cooldown stamp (LastMintAt)
|
||||
// under the per-user mutex — LastMintAt is only ever touched under Mu. Exported
|
||||
// so the payments re-issue path's coordination contract is actionable (Round 2
|
||||
// Loop A finding 2): a FRESH-charge terminal-success path that consumed the
|
||||
// code at the gate (consume=true, so ConsumePendingCode is not called) can call
|
||||
// this to re-arm immediate re-minting after a completed charge.
|
||||
// under the per-user mutex — LastMintAt is only ever touched under Mu. A no-op
|
||||
// when the user's state IS the shared saturated singleton (Round 2 Loop B
|
||||
// finding 3a: the shared stamp must not be cleared for every untracked user by
|
||||
// one user's terminal success).
|
||||
//
|
||||
// Round 2 Loop B finding 6a — COORDINATION CONTRACT (money agent): the FRESH
|
||||
// saved-card charge path consumes the customer's 2FA code AT THE GATE
|
||||
// (consume=true), so twofa.ConsumePendingCode is NOT called on its terminal
|
||||
// success and the mint-cooldown stamp survives — a customer who completes a
|
||||
// fresh charge within 60s of their last code mint and immediately requests a
|
||||
// new code gets 429 for the rest of the window. The money agent's fresh-charge
|
||||
// terminal-success path in handlers/payments/handlers.go should call
|
||||
// ClearMintCooldownForUser(userID) at the same point a completed charge is
|
||||
// recorded, so a just-completed charge re-arms immediate re-minting. This
|
||||
// function is the exported, coordination-actionable entry point for that call.
|
||||
func ClearMintCooldownForUser(userID string) {
|
||||
st := StateFor(userID)
|
||||
if st == saturatedLockedState {
|
||||
return
|
||||
}
|
||||
st.Mu.Lock()
|
||||
st.LastMintAt = time.Time{}
|
||||
st.Mu.Unlock()
|
||||
|
||||
@@ -223,3 +223,83 @@ func TestConsumePendingCode_ClearsMintCooldownStamp(t *testing.T) {
|
||||
defer st.Mu.Unlock()
|
||||
require.True(t, st.LastMintAt.IsZero(), "terminal-success consumption must clear the mint-cooldown stamp (finding 2)")
|
||||
}
|
||||
|
||||
// TestStateFor_SaturatedMintStampIsNoOp pins Round 2 Loop B finding 3a: the
|
||||
// SHARED saturated state must never carry a per-user mint-cooldown stamp.
|
||||
// StateFor returns the package singleton to every untracked user once the map
|
||||
// is at capacity, so a mint stamped on it would throttle all of them for the
|
||||
// whole cooldown (one user's mint blocks everyone for 60s) and
|
||||
// ClearMintCooldownForUser would clear it for everyone. The no-op keeps the
|
||||
// shared stamp permanently zeroed.
|
||||
func TestStateFor_SaturatedMintStampIsNoOp(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 10_000
|
||||
MapMu.Unlock()
|
||||
})
|
||||
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 1
|
||||
MapMu.Unlock()
|
||||
|
||||
now := clock.Now()
|
||||
MapMu.Lock()
|
||||
victim := &AttemptState{}
|
||||
victim.SetLastActive(now)
|
||||
victim.Count.Store(MaxAttempts) // in-window locked-out — protected from eviction
|
||||
Map["victim"] = victim
|
||||
MapMu.Unlock()
|
||||
|
||||
st := StateFor("untracked") // saturated — shared permanently-locked state
|
||||
require.Same(t, st, saturatedLockedState, "a saturated map must return the shared permanently-locked state")
|
||||
|
||||
st.SetLastMintAtLocked(clock.Now())
|
||||
require.True(t, st.LastMintAt.IsZero(), "a mint stamp written to the saturated state must be a no-op (cross-user throttle)")
|
||||
|
||||
ClearMintCooldownForUser("untracked")
|
||||
require.True(t, st.LastMintAt.IsZero(), "clearing the cooldown for one saturated user must not touch the shared stamp")
|
||||
}
|
||||
|
||||
// TestStateFor_NeverEvictsInWindowCounter pins Round 2 Loop B finding 3b: the
|
||||
// cap-driven eviction must never drop an in-window record carrying a NON-ZERO
|
||||
// attempt counter — a genuine user mid-window with failed attempts banked.
|
||||
// Evicting it would silently reset the counter and grant a fresh guessing
|
||||
// budget, so only count==0 in-window records (idle mint-cooldown stamps / fresh
|
||||
// lookups) are evictable. When every in-window record is protected, a new key
|
||||
// falls back to the shared saturated state instead.
|
||||
func TestStateFor_NeverEvictsInWindowCounter(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 10_000
|
||||
MapMu.Unlock()
|
||||
})
|
||||
|
||||
MapMu.Lock()
|
||||
Map = make(map[string]*AttemptState)
|
||||
MaxTrackedAttempts = 2
|
||||
MapMu.Unlock()
|
||||
|
||||
now := clock.Now()
|
||||
for _, id := range []string{"genuine_a", "genuine_b"} {
|
||||
st := &AttemptState{}
|
||||
st.SetLastActive(now)
|
||||
st.Count.Store(2) // in-progress counter, NOT locked out
|
||||
Map[id] = st
|
||||
}
|
||||
MapMu.Lock()
|
||||
require.Len(t, Map, 2)
|
||||
MapMu.Unlock()
|
||||
|
||||
st := StateFor("new_user")
|
||||
require.True(t, st.LockedOut(clock.Now()), "with every in-window record protected, a new key must fall back to the shared locked state")
|
||||
|
||||
MapMu.Lock()
|
||||
defer MapMu.Unlock()
|
||||
require.Len(t, Map, 2, "in-window records with count>0 must never be evicted (finding 3b)")
|
||||
for _, id := range []string{"genuine_a", "genuine_b"} {
|
||||
require.NotNil(t, Map[id], "%s must survive cap pressure", id)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user