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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user