fix: comprehensive payment system hardening (4 review passes)
CRITICAL fixes: - C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode) - C2: OverrideAmount validated post-substitution (prevents negative money minting) - C3: Terminal gift-card payments store gift_card_id; refund credits user balance - C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead) - C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation) - C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund) MAJOR fixes: - M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed - M3: ProcessCancellationRefund returns commit error (was swallowed) - M5: Dispute webhook handling (created + state.updated + disputes table) MEDIUM fixes: - ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any) - ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens - ME3: Webhook handlers now mutate state (payment.updated, refund.updated) Frontend fixes: - Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows - CHARGE_AND_STORE intent for save-card flows (SCA compliance) - Nonce staleness check verified across all flows Additional fixes from adversarial re-review: - F1: Till-sale completed dedup echoes stored amount (C4-class) - F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class) - F3: Square-success UPDATE checks RowsAffected (till sales) - F4: Dispute reason truncated to 192 chars (prevents INSERT failure) - F5: Booking-user lookup failure marks refund failed (prevents silent money loss) - F6: Saved-card/tip rechecks wrapped in transaction (C5 residual) Tests: - 15 adversarial attack tests (negative override, zero override, terminal gift card, refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge amount, raw PAN, missing auth, gift card balance, concurrent refunds) - 14 webhook state tests (dispute created/state, payment/refund updated) - 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test - Full suite passes with -race (25 packages, 0 failures) 25 files changed, +1532/-275 lines
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiFetch } from '$lib/utils/api';
|
||||
import { isNonceStale } from '$lib/square/square';
|
||||
import { isNonceStale, submitPaymentWithRetry } from '$lib/square/square';
|
||||
|
||||
const LOYALTY_DISCOUNT_RATE = 0.1;
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
// verification tokens expire after ~5 minutes, so a stale pair is discarded
|
||||
// on late retries and re-tokenized instead of rejected by Square.
|
||||
let newCardTokenizedAt = $state(0);
|
||||
// Save intent at tokenization time: the SCA verification token is bound to
|
||||
// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
|
||||
// tokenize must force a fresh tokenization rather than reuse a token minted
|
||||
// with the wrong intent.
|
||||
let newCardTokenizedForSaveCard = $state(false);
|
||||
let paymentResult = $state<{
|
||||
id: string;
|
||||
amount: number;
|
||||
@@ -374,17 +379,26 @@
|
||||
// is one-shot; the backend idempotency key dedups). The verification
|
||||
// token is amount-bound, so a changed amount forces a fresh
|
||||
// tokenization.
|
||||
if (!newCardNonce || isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)) {
|
||||
if (
|
||||
!newCardNonce ||
|
||||
newCardTokenizedForSaveCard !== saveCard ||
|
||||
isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountCents)
|
||||
) {
|
||||
try {
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(amountCents, {
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
});
|
||||
const tokenized = await cardSelection.tokenizeWithVerification(
|
||||
amountCents,
|
||||
{
|
||||
givenName: authStore.currentUser?.firstName,
|
||||
familyName: authStore.currentUser?.lastName,
|
||||
email: authStore.currentUser?.email
|
||||
},
|
||||
saveCard
|
||||
);
|
||||
newCardNonce = tokenized.nonce;
|
||||
newCardVerificationToken = tokenized.verificationToken ?? '';
|
||||
newCardTokenAmount = amountCents;
|
||||
newCardTokenizedAt = Date.now();
|
||||
newCardTokenizedForSaveCard = saveCard;
|
||||
} catch (_err) {
|
||||
status = 'error';
|
||||
const msg = _err instanceof Error ? _err.message : 'Card entry failed';
|
||||
@@ -423,18 +437,20 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
const response = await submitPaymentWithRetry(() =>
|
||||
apiFetch(`/api/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountCents,
|
||||
payment_type: paymentType,
|
||||
...(cardId ? { card_id: cardId } : {}),
|
||||
...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
|
||||
...(verificationToken ? { verification_token: verificationToken } : {}),
|
||||
idempotency_key: payIdempotencyKey
|
||||
})
|
||||
})
|
||||
});
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
@@ -452,6 +468,7 @@
|
||||
newCardVerificationToken = '';
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
paymentResult = {
|
||||
id: data.id,
|
||||
amount: data.amount,
|
||||
@@ -477,6 +494,7 @@
|
||||
newCardVerificationToken = '';
|
||||
newCardTokenAmount = 0;
|
||||
newCardTokenizedAt = 0;
|
||||
newCardTokenizedForSaveCard = false;
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user