fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)

Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent faceb9809c
commit fe88f2084d
55 changed files with 4180 additions and 971 deletions
+197
View File
@@ -485,6 +485,14 @@ type squareDisputedPaymentField struct {
type squarePaymentPayload struct {
ID string `json:"id"`
Status string `json:"status"` // "APPROVED", "COMPLETED", "CANCELED", "FAILED", "PENDING"
// ReferenceID / IdempotencyKey / AmountMoney feed the B1-support orphan
// detection (detectOrphanedReplayCharge): Square's Payment object carries
// them, and a sweep-minted duplicate charge preserves the origin row's
// idempotency key / reference_id / amount (the sweep replays the stored
// request verbatim).
ReferenceID string `json:"reference_id"`
IdempotencyKey string `json:"idempotency_key"`
AmountMoney *squareMoneyPayload `json:"amount_money"`
}
// squareRefundPayload maps the Square Refund (PaymentRefund) fields this app
@@ -768,6 +776,175 @@ func markPaymentFailed(ctx context.Context, paymentID string) error {
return nil
}
// squarePaymentKnown reports whether ANY local payments row carries the Square
// payment id, whatever its status. The pending-only UPDATE in
// handlePaymentUpdated matches zero rows both when no row exists at all and
// when the row already settled (e.g. a completed webhook replay of an
// already-'completed' charge). This existence check distinguishes those two:
// a row existing means the event is a plain no-op replay of a known charge,
// while no row at all is the signature of an ORPHANED sweep-minted duplicate.
// Unlike findPaymentBySquareID (which swallows errors), a DB failure here
// propagates so the caller rejects the event and Square retries.
func squarePaymentKnown(ctx context.Context, squarePaymentID string) (bool, error) {
var one int
err := db.Conn.QueryRow(ctx,
`SELECT 1 FROM payments WHERE square_payment_id = $1 LIMIT 1`, squarePaymentID).Scan(&one)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// findPendingByOrphanKeys locates the pending ORIGIN row of a likely
// sweep-minted duplicate charge: the pending row that shares the replayed
// charge's idempotency key (primary — exact, because Square returns the key on
// the Payment object and payments.idempotency_key is UNIQUE) or, failing that,
// the pending row whose booking/gift-card reference AND amount match the
// payload's reference_id/amount_money (the sweep replays the stored snapshot
// verbatim, preserving both). Only 'pending' rows WITHOUT a square_payment_id
// are candidates — that is exactly the population the keyed stale-pending sweep
// replays (sweep.go), so a match is the sweep-minted duplicate's origin.
func findPendingByOrphanKeys(ctx context.Context, payment squarePaymentPayload) (paymentID, bookingID string, found bool, err error) {
if payment.IdempotencyKey != "" {
var pid string
var bid *string
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id FROM payments
WHERE status = 'pending' AND idempotency_key = $1 AND square_payment_id IS NULL
ORDER BY created_at DESC, id DESC
LIMIT 1
`, payment.IdempotencyKey).Scan(&pid, &bid)
if err == nil {
if bid != nil {
bookingID = *bid
}
return pid, bookingID, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", false, err
}
}
// reference_id fallback: booking charges carry the booking id as
// reference_id; a gift-card-linked payment carries the card code. The
// amount guard stops a coincidental reference match (a pending row for the
// same booking at a different amount) from being mistaken for the origin.
if payment.ReferenceID != "" && payment.AmountMoney != nil && payment.AmountMoney.Amount > 0 {
amount := float64(payment.AmountMoney.Amount) / 100.0
var pid string
var bid *string
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id FROM payments
WHERE status = 'pending' AND square_payment_id IS NULL
AND ABS(amount - $2) < 0.005
AND (booking_id = $1 OR gift_card_id = $1)
ORDER BY created_at DESC, id DESC
LIMIT 1
`, payment.ReferenceID, amount).Scan(&pid, &bid)
if err == nil {
if bid != nil {
bookingID = *bid
}
return pid, bookingID, true, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return "", "", false, err
}
}
return "", "", false, nil
}
// orphanReplayChargeNotificationID derives the deterministic admin_notifications
// id for an orphaned sweep-minted duplicate charge's critical_payment_log
// notification: 'O' + 11 lowercase hex chars of a SHA-256 over
// 'orphan-replay-<payment_id>'. Mirrors disputeNotificationID ('D') and
// unknownEventNotificationID ('U'): same prefix + hex(sha256(input))[:11]
// scheme with a distinct uppercase prefix, so the three id spaces can never
// collide and the uppercase prefix guarantees no collision with a DB-generated
// id (generate_*_id emits 12 lowercase hex chars). The id is stable per ORIGIN
// payment row, so ON CONFLICT (id) DO NOTHING keeps re-deliveries and
// re-notifications of the same orphan charge to one row.
func orphanReplayChargeNotificationID(paymentID string) string {
sum := sha256.Sum256([]byte("orphan-replay-" + paymentID))
return "O" + hex.EncodeToString(sum[:])[:11]
}
// insertOrphanReplayChargeNotification surfaces an orphaned sweep-minted
// duplicate charge in the admin notification centre (reason
// 'critical_payment_log'), deduped by the deterministic per-origin-row id so
// repeated deliveries of the same orphan charge's event never add a second
// row. booking_id is set when the origin payment row has one, giving the owner
// a booking to act from. Best-effort: an insert failure is logged, never a
// dispatch error.
func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookingID string) {
if paymentID == "" {
return
}
id := orphanReplayChargeNotificationID(paymentID)
var bid any
if bookingID != "" {
bid = bookingID
}
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())
ON CONFLICT (id) DO NOTHING
`, id, bid)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert orphaned-replay admin notification: %v", err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] Inserted orphaned-replay-charge admin notification (origin payment=%s, booking=%q)", paymentID, bookingID)
}
}
// detectOrphanedReplayCharge handles a COMPLETED payment.updated event whose
// square_payment_id matches NO local payments row — the signature of an
// ORPHANED SWEEP-MINTED duplicate charge (B1-support). When the stale-pending
// sweep replays a pending row's stored idempotency key against Square and the
// key has expired, Square creates a NEW charge under that same key; the new
// charge's payment.updated event arrives here with no local row of its own,
// while the pending ORIGIN row (the one the sweep replayed) still exists and
// shares the replayed idempotency key — and, for booking/gift-card charges,
// the same reference_id and amount.
//
// Action (the webhook half of B1): mark the origin row 'failed' — it is NOT
// the charge that completed, so rescuing it to 'completed' would hide the
// duplicate behind the original and rescuing by square_payment_id is
// impossible (the orphan has no row) — and surface a deduped 'orphaned replay
// charge detected' admin notification. The AUTO-REFUND of the orphan charge is
// the SWEEP's job (sweep.go, the money agent's B1 fix): this path NEVER issues
// a refund and must never be turned into one; it detects + notifies + settles
// the origin row so the sweep does not blind-fail or double-rescue it later.
func detectOrphanedReplayCharge(ctx context.Context, payment squarePaymentPayload) error {
originID, bookingID, found, err := findPendingByOrphanKeys(ctx, payment)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Orphan-replay origin lookup failed for square payment %s: %v", payment.ID, err)
return err
}
if !found {
log.Printf("[SQUARE-WEBHOOK] payment.updated: COMPLETED square payment %s matches no local row and no pending origin row by idempotency key/reference_id — acknowledging (not a sweep-minted duplicate)", payment.ID)
return nil
}
tag, err := db.Conn.Exec(ctx,
`UPDATE payments SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`,
originID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to mark orphaned-replay origin payment %s failed: %v", originID, err)
return err
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] CRITICAL: COMPLETED square payment %s (orphaned sweep-minted duplicate) — origin pending payment %s marked failed; admin notified", payment.ID, originID)
} else {
log.Printf("[SQUARE-WEBHOOK] CRITICAL: COMPLETED square payment %s (orphaned sweep-minted duplicate) — origin payment %s already resolved; admin notified", payment.ID, originID)
}
insertOrphanReplayChargeNotification(ctx, originID, bookingID)
return nil
}
// handlePaymentUpdated reconciles a Square Payment state change against the
// local payments row (real-time counterpart to the stale-pending sweep). The
// Square id is logged, never the payload (PII). Idempotent: the UPDATE is a
@@ -818,6 +995,26 @@ func handlePaymentUpdated(data json.RawMessage) error {
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] payment.updated: square payment %s → local status %s", payment.ID, localStatus)
} else if localStatus == "completed" {
// B1-support: a COMPLETED payment.updated that matched NO pending
// 'payments' row may be an ORPHANED SWEEP-MINTED duplicate — the
// stale-pending sweep replayed a stored idempotency key against an
// expired key, Square landed a NEW charge whose id matches nothing
// locally, and this is that new charge's completion event. If NO local
// row carries this square_payment_id at all (not even a settled one),
// hunt for the pending origin row and settle it so the sweep never
// blind-fails or double-rescues it. A settled row existing means this
// is a plain no-op replay of a known charge and is left untouched.
known, kErr := squarePaymentKnown(ctx, payment.ID)
if kErr != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to check whether square payment %s is known: %v", payment.ID, kErr)
return kErr
}
if !known {
if dErr := detectOrphanedReplayCharge(ctx, payment); dErr != nil {
return dErr
}
}
}
// A Square charge can also map to a till_sales row (online gift-card
// purchase, retail at the till) — reconcile those too. Same pending-only
@@ -886,6 +886,216 @@ func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
}
}
// =============================================================================
// B1-support — orphaned sweep-minted duplicate charge (payment.updated with no
// local row that resolves to a pending origin row)
// =============================================================================
// createWebhookTestPendingOrigin inserts a pending payments row that carries an
// idempotency key but NO square_payment_id — exactly the population the keyed
// stale-pending sweep replays — and returns its local id.
func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string {
t.Helper()
var id string
err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
RETURNING id
`, idempotencyKey).Scan(&id)
if err != nil {
t.Fatalf("failed to create pending origin payment: %v", err)
}
return id
}
func countOrphanReplayNotifications(t *testing.T, originID string) int {
t.Helper()
var n int
if err := db.Conn.QueryRow(context.Background(),
`SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log'`,
orphanReplayChargeNotificationID(originID)).Scan(&n); err != nil {
t.Fatalf("failed to count orphan-replay notifications: %v", err)
}
return n
}
// TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed locks the
// B1-support webhook half: a COMPLETED payment.updated for a charge whose
// square_payment_id matches no local row — the sweep-minted duplicate — finds
// its pending origin row by the replayed idempotency key, marks it failed (not
// rescued), and raises exactly one deduped admin notification.
func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) {
const (
orphanSquareID = "sqp_orphan_c2"
idemKey = "b1-orphan-key-001"
)
originID := createWebhookTestPendingOrigin(t, idemKey)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_c2_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"idempotency_key": "` + idemKey + `",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// The origin row must be settled to 'failed' — NOT rescued to 'completed'
// (which would hide the duplicate behind the original charge).
if got := getPaymentStatus(t, originID); got != "failed" {
t.Errorf("expected origin pending payment 'failed', got %q", got)
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
}
// Re-delivery under a FRESH event_id (bypassing the handler's event_id
// dedup) must not add a second notification — the deterministic per-origin
// id dedups (and, by extension, the sweep's auto-refund cannot be
// double-triggered by this path).
event.EventID = "evt_orphan_c2_2"
w2 := deliverWebhook(t, event)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String())
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected notification count to stay 1 after re-delivery, got %d", n)
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop verifies the orphan
// detection is a no-op when no pending origin row matches: the event is
// acknowledged 200 without touching any row or raising a notification.
func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
const orphanSquareID = "sqp_orphan_noorigin"
before := countCriticalNotifications(t)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_noorigin_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"idempotency_key": "b1-orphan-key-never-used",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected no new critical notification with no origin match, got %d", n)
}
if got := countWebhookEvents(t, event.EventID); got != 1 {
t.Errorf("expected 1 dedup row, got %d", got)
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback locks the
// reference_id fallback of the origin lookup: a COMPLETED orphan event whose
// payload carries no idempotency key still finds its pending origin row via the
// replayed reference_id + matching amount.
func TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback(t *testing.T) {
const (
orphanSquareID = "sqp_orphan_refc2"
refID = "b16bad0000aa"
)
var originID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
RETURNING id
`, refID).Scan(&originID); err != nil {
t.Fatalf("failed to create reference-origin payment: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_refc2_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"reference_id": "` + refID + `",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, originID); got != "failed" {
t.Errorf("expected reference-matched origin payment 'failed', got %q", got)
}
if n := countOrphanReplayNotifications(t, originID); n != 1 {
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
}
}
// TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection verifies the orphan
// detection NEVER fires for a charge that already has a local row in a settled
// status (a plain payment.updated replay of a known charge): the row is left
// untouched and no notification is raised.
func TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection(t *testing.T) {
const squarePaymentID = "sqp_settled_replay"
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
before := countCriticalNotifications(t)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_settled_replay_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "b1-settled-key",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected settled payment to stay 'completed', got %q", got)
}
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected no new critical notification for a known settled row, got %d", n)
}
}
// =============================================================================
// State mutation — refund.updated
// =============================================================================