T09 · Insecure Skill Coding Practices
- Location
payment-patterns.md:660- Finding
Unauthenticated Payment Webhook Can Trigger Repeated Fulfillment
- Content
View full analysis
{ const { invoiceId } = req.body; const invoice = await invoiceService.getInvoice(invoiceId); if (invoice.status !== 'paid') return res.status(400).send('Not paid'); const order = await getOrderByInvoice(invoiceId); // Update order status await updateOrder(order.id, { status: 'paid', paidAt: new Date() }); // Send confirmation email await sendOrderConfirmation(order); // Trigger fulfillment await startFulfillment(order); res.send('OK'); }); ``` ### Technical Analysis The webhook accepts an attacker-controlled `invoiceId` and performs security-sensitive business operations without authenticating the sender. It does not verify an HMAC signature, provider identity, timestamp, nonce, or event identifier. Checking the current invoice status does not authenticate the request. If an invoice is legitimately paid, any party that discovers or guesses its identifier can submit the endpoint request. The handler also lacks an atomic idempotency check, so replayed or concurrent requests can repeatedly invoke `sendOrderConfirmation` and `startFulfillment`. ### Attack Path 1. An attacker obtains a valid paid invoice identifier through logs, URLs, browser history, predictable identifier generation, or another information disclosure. 2. The attacker sends a forged request: ```http POST /webhooks/payment Content-Type: application/json {"invoiceId":"VALID_PAID_INVOICE"} ``` 3. The server observes that the invoice is paid and accepts the unauthenticated request. 4. The attacker repeats the request or sends multiple concurrent requests. 5. The application repeatedly starts fulfillment and sends confirmations unless downstream systems indepen ...[truncated 499 chars]- Remediation
View remediation
{ verifyWebhookSignature(req.rawBody, req.headers, webhookSecret); const event = parseAndValidateWebhook(req.rawBody); const claimed = await atomicallyClaimWebhookEvent( event.id, event.invoiceId ); if (!claimed) { return res.status(200).send('Already processed'); } await startFulfillmentOnce(event.invoiceId); res.send('OK'); }); ``` ]]>
