Skip to content

Commit 5a34b0d

Browse files
committed
feat: enhance payment controller security by using transaction ID for server-side validation
1 parent 9e3decd commit 5a34b0d

File tree

3 files changed

+171
-111
lines changed

3 files changed

+171
-111
lines changed

docs/secure-payment-confirmation.md

Lines changed: 127 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,22 @@ $clientPaymentData = CollectedClientPaymentData::create($paymentData);
169169

170170
### Using the Payment Controller
171171

172-
The Stimulus payment controller simplifies SPC integration on the client side:
172+
The Stimulus payment controller simplifies SPC integration on the client side.
173+
174+
**SECURITY NOTICE:** For security reasons, payment details (amount, payee, etc.) are NOT passed via HTML attributes, as these can be tampered with by the client. Instead, you pass a secure `transaction-id`, and the server fetches the actual payment details from its database.
173175

174176
```html
175177
<form data-controller="webauthn--payment"
176178
data-action="submit->webauthn--payment#confirmPayment"
177179
data-webauthn--payment-options-url-value="/api/payment/options"
178180
data-webauthn--payment-result-url-value="/api/payment/verify"
179-
data-webauthn--payment-payee-name-value="Merchant Store"
180-
data-webauthn--payment-payee-origin-value="https://merchant.example.com"
181-
data-webauthn--payment-amount-value="99.99"
182-
data-webauthn--payment-currency-value="USD"
183-
data-webauthn--payment-instrument-display-name-value="Visa •••• 1234"
184-
data-webauthn--payment-instrument-icon-value="https://example.com/visa-icon.png">
181+
data-webauthn--payment-transaction-id-value="txn_abc123def456">
185182

186183
<h2>Confirm Payment</h2>
187-
<p>Amount: <strong>$99.99 USD</strong></p>
188-
<p>Merchant: <strong>Merchant Store</strong></p>
189-
<p>Payment Method: <strong>Visa •••• 1234</strong></p>
184+
<!-- Display payment details from server-side data -->
185+
<p>Amount: <strong><?= htmlspecialchars($transaction->getFormattedAmount()) ?></strong></p>
186+
<p>Merchant: <strong><?= htmlspecialchars($transaction->getPayeeName()) ?></strong></p>
187+
<p>Payment Method: <strong><?= htmlspecialchars($transaction->getInstrumentDisplay()) ?></strong></p>
190188

191189
<input type="hidden" data-webauthn--payment-target="result">
192190
<button type="submit">Confirm Payment</button>
@@ -199,28 +197,26 @@ The Stimulus payment controller simplifies SPC integration on the client side:
199197
|--------|------|---------|-------------|
200198
| `optionsUrl` | String | `/payment/options` | URL to fetch payment options |
201199
| `resultUrl` | String | `/payment/verify` | URL to verify payment result |
202-
| `payeeName` | String | - | Name of the payee (merchant) |
203-
| `payeeOrigin` | String | - | Origin of the payee |
204-
| `amount` | String | - | Payment amount |
205-
| `currency` | String | `USD` | Payment currency (ISO 4217) |
206-
| `instrumentDisplayName` | String | - | Display name for payment instrument |
207-
| `instrumentIcon` | String | - | Icon URL for payment instrument |
200+
| `transactionId` | String | - | **Secure transaction ID** (server fetches payment details) |
208201
| `submitViaForm` | Boolean | `false` | Submit credential via form instead of API |
209202
| `successRedirectUri` | String | - | URI to redirect to on success |
210203

204+
**Security Note:** Payment details (amount, payee, merchant) are **intentionally NOT configurable** via HTML attributes to prevent client-side tampering. The server must fetch these from its database using the `transactionId`.
205+
211206
### Controller Events
212207

213208
The payment controller dispatches several custom events:
214209

215210
```javascript
216211
// Connection event
217212
document.addEventListener('webauthn:payment:connect', (event) => {
218-
console.log('Payment controller connected', event.detail);
213+
console.log('Payment controller connected');
214+
console.log('Transaction ID:', event.detail.transactionId);
219215
});
220216

221217
// Options request/response events
222218
document.addEventListener('webauthn:payment:options:request', (event) => {
223-
console.log('Requesting payment options', event.detail.data);
219+
console.log('Requesting payment options for transaction:', event.detail.data.transactionId);
224220
});
225221

226222
document.addEventListener('webauthn:payment:options:success', (event) => {
@@ -283,22 +279,40 @@ class PaymentController
283279
{
284280
$data = json_decode($request->getContent(), true);
285281

286-
// Create payment extension
282+
// SECURITY: Fetch payment details from database using transaction ID
283+
// This prevents client-side tampering of amounts/payee
284+
$transactionId = $data['transactionId'] ?? null;
285+
if (!$transactionId) {
286+
return new JsonResponse(['error' => 'Transaction ID required'], 400);
287+
}
288+
289+
// Get transaction from secure database
290+
$transaction = $this->transactionRepository->findOneBy(['id' => $transactionId]);
291+
if (!$transaction || $transaction->getUserId() !== $this->getUser()->getId()) {
292+
return new JsonResponse(['error' => 'Transaction not found'], 404);
293+
}
294+
295+
// Verify transaction is in pending state
296+
if ($transaction->getStatus() !== 'pending') {
297+
return new JsonResponse(['error' => 'Transaction already processed'], 400);
298+
}
299+
300+
// Create payment extension with SERVER-VALIDATED data
287301
$amount = PaymentCurrencyAmount::create(
288-
$data['payment']['total']['currency'],
289-
$data['payment']['total']['value']
302+
$transaction->getCurrency(),
303+
$transaction->getAmount()
290304
);
291305

292306
$instrument = PaymentCredentialInstrument::create(
293-
$data['payment']['instrument']['displayName'] ?? 'Card',
294-
$data['payment']['instrument']['icon'] ?? 'https://example.com/default-icon.png'
307+
$transaction->getPaymentMethod()->getDisplayName(),
308+
$transaction->getPaymentMethod()->getIconUrl()
295309
);
296310

297311
$paymentExtension = PaymentExtension::register(
298312
rpId: 'example.com',
299-
topOrigin: 'https://merchant.example.com',
300-
payeeName: $data['payment']['payeeName'],
301-
payeeOrigin: $data['payment']['payeeOrigin'],
313+
topOrigin: $transaction->getMerchantOrigin(),
314+
payeeName: $transaction->getPayeeName(),
315+
payeeOrigin: $transaction->getPayeeOrigin(),
302316
amount: $amount,
303317
instrument: $instrument
304318
);
@@ -311,8 +325,9 @@ class PaymentController
311325
extensions: new AuthenticationExtensions([$paymentExtension])
312326
);
313327

314-
// Store challenge in session for verification
328+
// Store challenge and transaction ID in session for verification
315329
$_SESSION['payment_challenge'] = base64_encode($options->challenge);
330+
$_SESSION['payment_transaction_id'] = $transactionId;
316331

317332
return new JsonResponse($options);
318333
}
@@ -368,28 +383,31 @@ class PaymentController
368383
<div class="payment-container">
369384
<h1>Checkout</h1>
370385

386+
<?php
387+
// Fetch transaction from secure database
388+
$transaction = $transactionRepository->find($transactionId);
389+
?>
390+
371391
<form data-controller="webauthn--payment"
372392
data-action="submit->webauthn--payment#confirmPayment"
373393
data-webauthn--payment-options-url-value="/api/payment/options"
374394
data-webauthn--payment-result-url-value="/api/payment/verify"
375-
data-webauthn--payment-payee-name-value="Acme Store"
376-
data-webauthn--payment-payee-origin-value="https://acme-store.example.com"
377-
data-webauthn--payment-amount-value="149.99"
378-
data-webauthn--payment-currency-value="USD"
379-
data-webauthn--payment-instrument-display-name-value="Visa •••• 4242"
380-
data-webauthn--payment-instrument-icon-value="/images/visa-logo.png"
395+
data-webauthn--payment-transaction-id-value="<?= htmlspecialchars($transaction->getId()) ?>"
381396
data-webauthn--payment-success-redirect-uri-value="/payment/success">
382397

383398
<div class="order-summary">
384399
<h2>Order Summary</h2>
385-
<p>Total: <strong>$149.99 USD</strong></p>
386-
<p>Merchant: <strong>Acme Store</strong></p>
400+
<!-- Display from server-side data, NOT from HTML attributes -->
401+
<p>Total: <strong><?= htmlspecialchars($transaction->getFormattedAmount()) ?></strong></p>
402+
<p>Merchant: <strong><?= htmlspecialchars($transaction->getPayeeName()) ?></strong></p>
387403
</div>
388404

389405
<div class="payment-method">
390406
<h3>Payment Method</h3>
391-
<img src="/images/visa-logo.png" alt="Visa" width="40">
392-
<span>Visa •••• 4242</span>
407+
<img src="<?= htmlspecialchars($transaction->getPaymentMethod()->getIconUrl()) ?>"
408+
alt="<?= htmlspecialchars($transaction->getPaymentMethod()->getBrand()) ?>"
409+
width="40">
410+
<span><?= htmlspecialchars($transaction->getPaymentMethod()->getDisplayName()) ?></span>
393411
</div>
394412

395413
<button type="submit" class="btn-primary">
@@ -450,12 +468,78 @@ if (browserSupportsWebAuthn()) {
450468

451469
## Security Considerations
452470

453-
1. **Always validate payment data** on the server side using `PaymentExtensionOutputChecker`
454-
2. **Use HTTPS** - SPC requires a secure context
455-
3. **Verify the challenge** matches what was sent to the client
456-
4. **Check the RP ID** matches your domain
457-
5. **Validate the payee origin** matches the expected merchant
458-
6. **Store credentials securely** using proper key management
471+
### Critical Security Measures
472+
473+
1. **NEVER trust client-side payment data**
474+
- Payment amounts, payee names, and merchant details must NEVER come from HTML attributes or JavaScript
475+
- Always fetch these from your secure server-side database using a transaction ID
476+
- The Stimulus controller is designed to only send a `transactionId` - do not modify it to accept payment details
477+
478+
2. **Server-side validation is mandatory**
479+
- Always validate payment data using `PaymentExtensionOutputChecker`
480+
- Verify the transaction belongs to the authenticated user
481+
- Check the transaction status (must be "pending")
482+
- Validate the amount hasn't been modified
483+
484+
3. **Transaction ID security**
485+
- Generate cryptographically secure transaction IDs (e.g., `bin2hex(random_bytes(16))`)
486+
- Store transaction state in your database with user association
487+
- Implement transaction expiry (e.g., 15 minutes)
488+
- Mark transactions as "completed" or "cancelled" after processing
489+
490+
4. **Standard WebAuthn security**
491+
- Use HTTPS - SPC requires a secure context
492+
- Verify the challenge matches what was sent to the client
493+
- Check the RP ID matches your domain
494+
- Validate the payee origin matches the expected merchant
495+
- Store credentials securely using proper key management
496+
497+
### Example: Secure Transaction Flow
498+
499+
```php
500+
// 1. Create transaction in database (server-side only)
501+
$transaction = new Transaction();
502+
$transaction->setId(bin2hex(random_bytes(16))); // Secure ID
503+
$transaction->setUserId($currentUser->getId());
504+
$transaction->setAmount('99.99');
505+
$transaction->setCurrency('USD');
506+
$transaction->setPayeeName('Merchant Store');
507+
$transaction->setStatus('pending');
508+
$transaction->setExpiresAt(new DateTime('+15 minutes'));
509+
$entityManager->persist($transaction);
510+
$entityManager->flush();
511+
512+
// 2. Render page with transaction ID only
513+
echo '<form data-webauthn--payment-transaction-id-value="' .
514+
htmlspecialchars($transaction->getId()) . '">';
515+
516+
// 3. In options endpoint: Fetch from database
517+
$transaction = $repository->findOneBy([
518+
'id' => $transactionId,
519+
'userId' => $currentUser->getId(),
520+
'status' => 'pending'
521+
]);
522+
523+
if (!$transaction || $transaction->isExpired()) {
524+
return new JsonResponse(['error' => 'Invalid transaction'], 400);
525+
}
526+
527+
// Use $transaction data for payment extension (NOT client data!)
528+
```
529+
530+
### Why This Matters
531+
532+
**Attack scenario without this protection:**
533+
1. User initiates payment for $10.00
534+
2. Attacker modifies HTML: `data-amount-value="0.01"`
535+
3. Without server validation, user pays only $0.01
536+
537+
**Protection with transaction ID:**
538+
1. User initiates payment for $10.00
539+
2. Server creates transaction with ID `txn_abc123` storing amount $10.00
540+
3. Attacker can modify HTML attributes, but server ignores them
541+
4. Server always uses database amount ($10.00) from transaction ID
542+
5. Payment is processed for the correct amount ✅
459543

460544
## Troubleshooting
461545

src/stimulus/PAYMENT_CONTROLLER.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
The Payment Controller provides Stimulus-based integration for Secure Payment Confirmation (SPC) using WebAuthn.
44

5+
## ⚠️ Security Notice
6+
7+
**This controller is designed to be secure by default.** Payment details (amount, payee, merchant) are **intentionally NOT** accepted via HTML attributes to prevent client-side tampering. Instead, you provide a secure `transaction-id`, and the server fetches the actual payment details from its database.
8+
59
## Installation
610

711
```bash
@@ -15,10 +19,10 @@ npm install @web-auth/webauthn-stimulus
1519
data-action="submit->webauthn--payment#confirmPayment"
1620
data-webauthn--payment-options-url-value="/api/payment/options"
1721
data-webauthn--payment-result-url-value="/api/payment/verify"
18-
data-webauthn--payment-payee-name-value="Merchant Store"
19-
data-webauthn--payment-payee-origin-value="https://merchant.example.com"
20-
data-webauthn--payment-amount-value="99.99"
21-
data-webauthn--payment-currency-value="USD">
22+
data-webauthn--payment-transaction-id-value="txn_abc123def456">
23+
24+
<!-- Display payment info from server-side data -->
25+
<p>Amount: <?= htmlspecialchars($transaction->getFormattedAmount()) ?></p>
2226

2327
<input type="hidden" data-webauthn--payment-target="result">
2428
<button type="submit">Confirm Payment</button>
@@ -29,18 +33,15 @@ npm install @web-auth/webauthn-stimulus
2933

3034
### Values
3135

32-
| Value | Type | Default | Description |
33-
|-------------------------|---------|--------------------|------------------------------------------|
34-
| `optionsUrl` | String | `/payment/options` | URL to fetch payment options from server |
35-
| `resultUrl` | String | `/payment/verify` | URL to verify payment credential |
36-
| `payeeName` | String | - | Name of the payee (merchant) |
37-
| `payeeOrigin` | String | - | Origin of the payee |
38-
| `amount` | String | - | Payment amount (e.g., "99.99") |
39-
| `currency` | String | `USD` | ISO 4217 currency code |
40-
| `instrumentDisplayName` | String | - | Display name for payment instrument |
41-
| `instrumentIcon` | String | - | URL to payment instrument icon |
42-
| `submitViaForm` | Boolean | `false` | Submit via form instead of API |
43-
| `successRedirectUri` | String | - | Redirect URL on success |
36+
| Value | Type | Default | Description |
37+
|----------------------|---------|-------------------|------------------------------------------|
38+
| `optionsUrl` | String | `/payment/options` | URL to fetch payment options from server |
39+
| `resultUrl` | String | `/payment/verify` | URL to verify payment credential |
40+
| `transactionId` | String | - | **Secure transaction ID** (server fetches payment details) |
41+
| `submitViaForm` | Boolean | `false` | Submit via form instead of API |
42+
| `successRedirectUri` | String | - | Redirect URL on success |
43+
44+
**🔒 Security Note:** Payment details (amount, payee, merchant) are **intentionally NOT configurable** via HTML attributes. The server must fetch these from its database using the `transactionId` to prevent client-side tampering.
4445

4546
### Targets
4647

0 commit comments

Comments
 (0)