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
+154 -8
View File
@@ -20,6 +20,7 @@ import (
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/adminnotify"
"github.com/jackc/pgx/v5"
)
@@ -670,13 +671,26 @@ func disputeNotificationID(squareDisputeID string) string {
// collapse every untracked dispute onto one unacknowledged NULL-booking row.
// The caller supplies a bounded context (webhookDBContext).
func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID string) {
// Round 2 Loop B finding 1: apply the global cap to this insert site too
// (the pre-check logs the suppression; the fold inside each INSERT enforces
// it atomically so concurrent events cannot overshoot together). The
// unacknowledged 'critical_payment_log' queue is shared by every insert
// site in the codebase (sweep, webhook, account-erasure, jobs), so a flood
// at any of them must not bury the single-operator notification centre.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed (booking=%q dispute=%q) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", bookingID, disputeID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
if disputeID != "" {
id := disputeNotificationID(disputeID)
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
ON CONFLICT (id) DO NOTHING
`, id)
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
@@ -699,7 +713,10 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID
AND an.booking_id IS NOT DISTINCT FROM $1
AND an.acknowledged_at IS NULL
)
`, bid)
AND (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
`, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
@@ -735,12 +752,22 @@ func unknownEventNotificationID(eventID string) string {
// failure is logged, never a dispatch error (the 501 is already the response).
// The caller supplies a bounded context (webhookDBContext).
func insertUnknownEventNotification(ctx context.Context, eventType, eventID string) {
// Round 2 Loop B finding 1: same atomic global cap as every other
// 'critical_payment_log' insert site — the pre-check logs the suppression,
// the INSERT fold enforces it atomically.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed for unhandled event %q (event_id=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", eventType, eventID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
id := unknownEventNotificationID(eventID)
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $2
ON CONFLICT (id) DO NOTHING
`, id)
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification for unhandled event %q (event_id=%s): %v", eventType, eventID, err)
return
@@ -874,6 +901,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
if paymentID == "" {
return
}
// Round 2 Loop B finding 1: same atomic global cap as every other
// 'critical_payment_log' insert site.
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
log.Printf("[SQUARE-WEBHOOK] orphaned-replay admin notification suppressed (origin payment=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", paymentID, adminnotify.MaxUnacknowledgedCriticalLogs)
return
}
id := orphanReplayChargeNotificationID(paymentID)
var bid any
if bookingID != "" {
@@ -881,9 +914,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
}
tag, err := db.Conn.Exec(ctx, `
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
VALUES ($1, 'critical_payment_log'::admin_notification_reason, $2, NOW())
SELECT $1, 'critical_payment_log'::admin_notification_reason, $2, NOW()
WHERE (SELECT COUNT(*) FROM admin_notifications _an
WHERE _an.reason = 'critical_payment_log'
AND _an.acknowledged_at IS NULL) < $3
ON CONFLICT (id) DO NOTHING
`, id, bid)
`, id, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert orphaned-replay admin notification: %v", err)
return
@@ -1149,7 +1185,20 @@ func handleRefundUpdated(data json.RawMessage) error {
return fmt.Errorf("refund.updated payload for data.id=%q missing/invalid refund object (id=%q status=%q): %w", env.ID, refund.ID, refund.Status, errWebhookParseFailure)
}
// Single shared Square → local refund-status mapping (payments package) so
// the webhook and the synchronous refund handlers can never drift.
// the webhook and the synchronous refund handlers can never drift. One
// deliberate webhook-only override: APPROVED. The shared mapping maps
// APPROVED→('completed', true) for the SYNCHRONOUS refund handlers, whose
// blocking APPROVED result is final. The webhook is event-driven — an
// APPROVED refund is still in flight at Square and may settle COMPLETED or
// FAILED afterwards. Promoting on APPROVED would stick the row at
// 'completed', and the FAILED demotion below only demotes 'pending' rows
// (the over-refund guard counts completed refunds — demoting would exclude
// money that already moved). Leave the row 'pending'; the Square status is
// logged for the audit trail.
if refund.Status == "APPROVED" {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status APPROVED is non-terminal for the webhook (Square may still settle COMPLETED or FAILED) — leaving the local row pending", refund.ID)
return nil
}
localStatus, terminal := payments.SquareRefundStatusToLocal(refund.Status)
if !terminal {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status %q is non-terminal — no local state change", refund.ID, refund.Status)
@@ -1170,6 +1219,18 @@ func handleRefundUpdated(data json.RawMessage) error {
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
}
// B1 sweep-dup refunds: a COMPLETED promotion must ALSO resolve the
// parent payment/till_sale row. The B1 re-poll pass (sweepPendingB1Refunds,
// refunds.go) only queries refunds rows still 'pending' — once this
// promotion flips the row to 'completed' the re-poll can never see it
// again, so the parent would stay pending forever and the stale-pending
// sweep would re-replay the expired idempotency key each run, minting a
// new charge every 5 minutes (HIGH, B1). Runs on every COMPLETED event
// so a row stranded by an earlier path is healed too; idempotent — the
// parent UPDATE is status='pending'-guarded and the clawback is claim-first.
if err := resolveSweepDupRefundParent(ctx, refund.ID); err != nil {
return err
}
return nil
case "failed":
// A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep
@@ -1199,6 +1260,91 @@ func handleRefundUpdated(data json.RawMessage) error {
return nil
}
// resolveSweepDupRefundParent resolves the parent row of a B1 sweep auto-refund
// of a replay-induced duplicate charge when the webhook promotes the refund to
// COMPLETED. The B1 re-poll pass (sweepPendingB1Refunds, refunds.go) only
// processes refunds rows still 'pending', so once THIS handler promotes the
// row to 'completed' the re-poll can never resolve the parent again — and a
// still-pending parent lets the stale-pending sweep re-replay its expired
// idempotency key every 5-minute run, minting a NEW charge each time (HIGH).
// This replicates the re-poll's resolveB1ParentFailed semantics here:
//
// - a payments-table refund (reason exactly "duplicate charge — sweep replay")
// is attached to the still-pending parent payment row — payment_id IS that
// row, so it is marked failed;
// - a till_sale refund carries the parent sale id in its reason ("(till_sale
// <id>)"), so the sale's funded gift card is clawed back and the sale marked
// failed, exactly like clawbackFailedTillSales.
//
// Idempotent: the parent UPDATE is status='pending'-guarded and the clawback is
// claim-first (payments.RevertGiftCardFunding), so a concurrent resolution by
// the sweep's own re-poll is a no-op. A non-nil error means a DB failure left
// the parent unresolved — the caller rejects the webhook so Square retries.
func resolveSweepDupRefundParent(ctx context.Context, squareRefundID string) error {
var paymentID, reason string
err := db.Conn.QueryRow(ctx, `
SELECT payment_id, reason FROM refunds WHERE square_refund_id = $1
`, squareRefundID).Scan(&paymentID, &reason)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Refund row already gone (deleted) — nothing to resolve.
return nil
}
log.Printf("[SQUARE-WEBHOOK] Failed to read refund %s for parent resolution: %v", squareRefundID, err)
return err
}
if !strings.HasPrefix(reason, "duplicate charge — sweep replay") {
return nil
}
if reason == "duplicate charge — sweep replay" {
tag, err := db.Conn.Exec(ctx, `
UPDATE payments SET status = 'failed', updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`, paymentID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to mark B1 parent payment %s failed: %v", paymentID, err)
return err
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED — marked parent payment %s failed", squareRefundID, paymentID)
}
return nil
}
if idx := strings.Index(reason, "(till_sale "); idx >= 0 {
tillSaleID := strings.TrimSuffix(reason[idx+len("(till_sale "):], ")")
return clawbackOneTillSaleByID(ctx, tillSaleID)
}
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED but parent could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", squareRefundID, reason)
return nil
}
// clawbackOneTillSaleByID loads a pending till sale by id and resolves it like
// a definitively failed charge: a gift-card sale has its funding reverted
// atomically with the failed mark; a sale with no gift card is only marked
// failed. Shares clawbackOneTillSale with clawbackFailedTillSales so the B1
// parent resolution and the failed-payment clawback can never drift.
func clawbackOneTillSaleByID(ctx context.Context, saleID string) error {
var (
itemType string
itemID sql.NullString
totalAmount float64
redeemedBy sql.NullString
isCreate *bool
)
err := db.Conn.QueryRow(ctx, `
SELECT ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
(ts.created_at = gc.created_at) AS is_create
FROM till_sales ts
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
WHERE ts.id = $1
`, saleID).Scan(&itemType, &itemID, &totalAmount, &redeemedBy, &isCreate)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to load till sale %s for B1 parent clawback: %v", saleID, err)
return err
}
return clawbackOneTillSale(ctx, saleID, itemType, itemID, totalAmount, redeemedBy, isCreate)
}
// truncateDisputeReason caps a Square dispute reason at the disputes.reason
// VARCHAR(192) column width. An over-long reason would fail the disputes
// INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so