fix(cart): validate bank redirect URL, add checkout idempotency key
FH-1.2 (partial): reject non-https bank redirect URLs before trust-bypass rendering them into the payment popup iframe; show a visible payment error instead. Popup-vs-navigate-current-tab redesign still open, gated on a product decision about the return-URL contract. FH-1.4 (frontend half): one crypto.randomUUID() idempotency key per checkout attempt, generated when the payment popup opens and reused across retryPayment(), sent as metadata.idempotencyKey - the only extensibility point @marketplaces/payment's PaymentRequest exposes. FH-0.2: confirmed done - createPayment(payload, headers) has zero callers, cart.component.ts already runs entirely through the server-priced checkout path.
This commit is contained in:
@@ -65,6 +65,11 @@ export class CartComponent implements OnDestroy {
|
||||
selectedPaymentMethod = signal<PaymentMethod>('qr');
|
||||
paymentId = signal<string>('');
|
||||
linkCopied = signal<boolean>(false);
|
||||
// FH-1.4: one key per checkout attempt, generated when the popup opens and
|
||||
// reused across retryPayment() - a double-click can't reach here twice
|
||||
// (checkoutInFlight guards the entry point above), and a retry after a
|
||||
// failed attempt must not read as a second payment for the same cart.
|
||||
private checkoutIdempotencyKey = '';
|
||||
|
||||
// Email collection after successful payment
|
||||
userEmail = signal<string>('');
|
||||
@@ -242,6 +247,7 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
openPaymentPopup(paymentMethod: PaymentMethod): void {
|
||||
this.checkoutIdempotencyKey = crypto.randomUUID();
|
||||
this.analytics.track('payment_started', { paymentMethod });
|
||||
this.showPaymentPopup.set(true);
|
||||
this.selectedPaymentMethod.set(paymentMethod);
|
||||
@@ -347,7 +353,10 @@ export class CartComponent implements OnDestroy {
|
||||
): void {
|
||||
this.paymentGateway.create(paymentMethod as PackagePaymentMethod, {
|
||||
checkoutSessionId: session.checkoutSessionId,
|
||||
metadata: { merchantReference },
|
||||
// PaymentRequest has no dedicated idempotency field (package contract,
|
||||
// node_modules/@marketplaces/payment) - metadata is the only
|
||||
// extensibility point, same as merchantReference already uses.
|
||||
metadata: { merchantReference, idempotencyKey: this.checkoutIdempotencyKey },
|
||||
}).subscribe({
|
||||
next: (attempt) => this.handlePaymentAttempt(attempt, paymentMethod),
|
||||
error: (err) => {
|
||||
@@ -482,10 +491,28 @@ export class CartComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// FH-1.2: bankUrl comes from the backend's own payment-attempt response
|
||||
// (attempt.action.url), so this isn't validating an arbitrary client
|
||||
// input - it's refusing to trust-bypass-render anything the backend
|
||||
// didn't actually hand back as a real https redirect target.
|
||||
if (!this.isHttpsUrl(bankUrl)) {
|
||||
console.error('Refusing to open bank payment popup: non-https redirect URL', bankUrl);
|
||||
this.setPaymentError();
|
||||
return;
|
||||
}
|
||||
|
||||
this.bankPaymentFrameUrl.set(this.sanitizer.bypassSecurityTrustResourceUrl(bankUrl));
|
||||
this.showBankPaymentPopup.set(true);
|
||||
}
|
||||
|
||||
private isHttpsUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
closeBankPaymentPopup(): void {
|
||||
this.showBankPaymentPopup.set(false);
|
||||
this.bankPaymentFrameUrl.set(null);
|
||||
|
||||
Reference in New Issue
Block a user