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:
sdarbinyan
2026-08-27 04:23:56 +04:00
parent c990c10307
commit 4876b59cf0
2 changed files with 45 additions and 16 deletions

View File

@@ -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);