fix: loop-B adversarial (503c326 baseline) — IDEMPOTENCY_KEY_REUSED reclassified ambiguous, 2FA reissue fail-closed alerts, family-cache crash window, consolidation regression checks
Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: - CRITICAL-ish: IDEMPOTENCY_KEY_REUSED (409) no longer classified as a definitive 402 in chargeFailureStatus — it means the ORIGINAL charge may have landed with a different body, so it is now AMBIGUOUS (503): the frontend keeps the same idempotency key, the pending row stays rescuable by the sweep (which already treated it as ambiguous), and the frontend no longer regenerates the key into a possible double charge. SCA verification-required codes remain definitive 402. - HIGH: reissueTwoFACodeAfterFailedCharge now writes a CRITICAL admin notification (insertCriticalPaymentNotification) when issuance is refused (missing pepper / unavailable delivery) instead of silently stranding the customer; documented that a pepper CHANGE invalidates all pending codes. - MEDIUM: family-alive cache invalidation crash window documented (invalidate-after- commit leaves up to 30s warm on a crash; the near-TTL DB re-check bounds it). - Consolidation regression checks (8b2fe3b helpers): writeChargeSnapshot guard preserved at all sites, postChargeRecheck identical, squareRefundStatusToLocal mappings verified, reissue fresh-only semantics confirmed at all 5 call sites. Verified: 26/26 dev packages, both vet tags, frontend tests + build, env-docs 42/42.
This commit is contained in:
@@ -203,6 +203,39 @@ func writeChargeSnapshot(ctx context.Context, q db.Querier, table, rowID string,
|
||||
}
|
||||
}
|
||||
|
||||
// writeChargeSnapshotUnconditional stores the verbatim request JSON on a
|
||||
// payments/till_sales row WITHOUT the first-attempt immutability guard that
|
||||
// writeChargeSnapshot applies. It is used ONLY by the gift-card (BuyGiftCard)
|
||||
// and till-sale (CreateTillSale) charge sites — the two sites that were
|
||||
// UNCONDITIONAL before the writeChargeSnapshot consolidation (Loop A, finding 1
|
||||
// regression check 4a). Their pending-reuse branches deliberately refresh
|
||||
// square_request_snapshot in the SAME transaction as the square_source_id
|
||||
// refresh (the Go-reencrypt refresh in giftcards.go / refreshTillSnapshotSource
|
||||
// in till.go, B6), and this post-commit write stores the FRESH full body for
|
||||
// the CURRENT attempt. The guard would wrongly skip this write on the reuse
|
||||
// path when the in-transaction refresh failed best-effort — the row would then
|
||||
// keep the stale first-attempt body while the live square_source_id column
|
||||
// already points at the new source (the pre-consolidation code explicitly
|
||||
// warned against "fixing" these sites into the guarded form). The booking/tip/
|
||||
// terminal saved-card paths keep the guarded writeChargeSnapshot: their reuse
|
||||
// paths do NOT refresh the snapshot, so the guard correctly records the
|
||||
// immutable first-attempt body.
|
||||
func writeChargeSnapshotUnconditional(ctx context.Context, q db.Querier, table, rowID string, body any, label string) {
|
||||
snap, mErr := json.Marshal(body)
|
||||
if mErr != nil {
|
||||
log.Printf("Failed to marshal square_request_snapshot for %s %s: %v", label, rowID, mErr)
|
||||
return
|
||||
}
|
||||
stored, eErr := encryptSnapshot(snap)
|
||||
if eErr != nil {
|
||||
log.Printf("Failed to encrypt square_request_snapshot for %s %s: %v", label, rowID, eErr)
|
||||
return
|
||||
}
|
||||
if _, sErr := q.Exec(ctx, `UPDATE `+table+` SET square_request_snapshot = $1 WHERE id = $2`, string(stored), rowID); sErr != nil {
|
||||
log.Printf("Failed to store square_request_snapshot for %s %s: %v", label, rowID, sErr)
|
||||
}
|
||||
}
|
||||
|
||||
// recheckBookingPayable re-reads the booking status after a Square charge
|
||||
// succeeded (R9): a concurrent cancellation/eviction can move the booking out
|
||||
// of a payable state between the pre-charge status check and the charge
|
||||
|
||||
Reference in New Issue
Block a user