Securing Payment Confirmations: Your Database is the Single Source of Truth
Imagine a user completing a payment process, seeing a 'success' message on their screen. The application then immediately sends a confirmation email. It sounds correct, but what if that 'success' message isn't what it seems? In the estrella-tour project, which handles reservations and payments, we recently tackled a critical vulnerability related to this exact scenario.
The Illusion of a "Successful" Redirect
Our previous implementation relied on the return URL from a payment gateway, like Mercado Pago, to determine if a payment was successful. When a user completed a payment, the gateway would redirect them back to our application with a URL parameter, for instance, status=success. The confirmation screen then used this parameter as the sole trigger to send a "reservation confirmed" email.
// Simplified example of the old logic (conceptual)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('status') === 'success') {
sendConfirmationEmail(reservationId);
// Mark email sent prematurely
updateEmailSentStatus(reservationId, true);
}
This approach, while seemingly straightforward, introduced a significant security flaw.
The Hidden Vulnerability
By simply manipulating the return URL to include status=success along with a valid reservation ID (which a user might know for their own booking), an attacker could trick our application into sending a fake confirmation email. This wasn't just about receiving an unearned email; it had two critical implications:
- False Confirmations: Users could receive confirmation for payments that had not actually been approved by the payment gateway, leading to potential service abuse.
- Blocking Real Emails: Once the fake email was sent, the system would mark
emailSent=trueprematurely. If the legitimate payment webhook (the true source of payment status updates) later confirmed the payment, the real confirmation email would be blocked because the system thought it had already been sent.
The core issue was relying on client-side or easily manipulated data (the URL) as the source of truth for a critical state change.
Restoring Truth: Database-First Validation
The fix involved a fundamental shift: never trusting external, easily modifiable input for critical state. Instead, we now enforce that the confirmation email is only sent after verifying the payment status directly from our database.
Our payment gateway uses webhooks to notify our backend of the definitive payment status. This backend process then updates the estadoPago (payment status) field in our database to APROBADO (APPROVED) once the payment is genuinely confirmed. The confirmation screen now queries the database directly.
// Simplified example of the new, secure logic (conceptual)
async function handlePaymentConfirmation(reservationId) {
const paymentStatus = await fetchPaymentStatusFromDatabase(reservationId);
if (paymentStatus === 'APPROVED') {
// Only send email if DB confirms payment
sendConfirmationEmail(reservationId);
} else {
// Handle pending, rejected, or unknown status
log.warn(`Payment status for ${reservationId} is not APPROVED: ${paymentStatus}`);
}
}
This ensures that the email is only dispatched when the authoritative source – our database, updated by a secure webhook – confirms the payment.
Architectural Principle: Trust But Verify
This incident reinforces a crucial architectural principle: for any critical action or state change, especially those involving financial transactions or user permissions, always validate the intent against your server-side, authoritative source of truth. Client-side signals and external redirects are hints, not guarantees. Your database, correctly updated by secure backend processes and webhooks, is the ultimate arbiter of truth.
Generated with Gitvlg.com