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:
2026-08-22 00:34:50 +01:00
parent 3866cc5963
commit 4e398a7a2b
31 changed files with 1703 additions and 342 deletions
+17 -3
View File
@@ -13,6 +13,7 @@ import (
"crussell/clock"
"crussell/db"
"crussell/internal/adminnotify"
"github.com/jackc/pgx/v5"
@@ -662,8 +663,18 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
}
// (ii) Surface the theft in the admin notification centre. The NOT
// EXISTS guard keeps ONE alert per reused family until an admin
// acknowledges it — mirroring insertCriticalPaymentNotification.
if _, err := tx.Exec(ctx, `
// acknowledges it — mirroring insertCriticalPaymentNotification — and
// the GLOBAL cap (adminnotify.MaxUnacknowledgedCriticalLogs) bounds the
// unacknowledged 'refresh_token_reuse' queue ATOMICALLY (Round 2 Loop B
// finding 1): without it a register-botnet — N accounts, each rotated
// once and replayed past the 60s grace — could bury the single-operator
// notification centre under unbounded alerts. The cap is folded into
// the INSERT's WHERE clause (count-then-insert is atomic, closing the
// TOCTOU), and the pre-check logs the suppression for operator
// visibility.
if adminnotify.CriticalLogsCapExceeded(ctx, tx, "refresh_token_reuse") {
slog.Error("CRITICAL: refresh token reuse detected but admin alert suppressed — unacknowledged 'refresh_token_reuse' queue at the cap", "userID", reusedUserID, "cap", adminnotify.MaxUnacknowledgedCriticalLogs)
} else if _, err := tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id, created_at)
SELECT 'refresh_token_reuse', $1, NOW()
WHERE NOT EXISTS (
@@ -672,7 +683,10 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
AND an.user_id = $1
AND an.acknowledged_at IS NULL
)
`, reusedUserID); err != nil {
AND (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'refresh_token_reuse'
AND _an.acknowledged_at IS NULL) < $2
`, reusedUserID, adminnotify.MaxUnacknowledgedCriticalLogs); err != nil {
slog.Error("CRITICAL: refresh token reuse detected but admin alert insert failed", "userID", reusedUserID, "err", err)
}
// Commit the family revocation + alert — NOT the deferred rollback.