Harden auth, payments, and frontend against review findings.

Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-06-24 19:59:02 +00:00
co-authored by Cursor
parent fc4af38e8a
commit a6840ea953
37 changed files with 1432 additions and 528 deletions
+30 -8
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
@@ -111,13 +111,23 @@ function stopBackgroundChecker(ticketId: string) {
*/
lnbitsRouter.post('/webhook', async (c) => {
try {
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
// webhook URL must carry a matching ?token=... (set when the invoice is created).
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
if (webhookSecret) {
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
if (provided !== webhookSecret) {
console.warn('LNbits webhook rejected: invalid or missing secret');
return c.json({ received: true, processed: false }, 401);
}
}
const payload: LNbitsWebhookPayload = await c.req.json();
// Log identifiers only (no full payload / PII)
console.log('LNbits webhook received:', {
paymentHash: payload.payment_hash,
status: payload.status,
amount: payload.amount,
extra: payload.extra,
});
// Verify the payment is actually complete by checking with LNbits
@@ -135,6 +145,20 @@ lnbitsRouter.post('/webhook', async (c) => {
return c.json({ received: true, processed: false }, 200);
}
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
// valid paid hash from any other invoice could be replayed with an arbitrary
// ticketId to confirm tickets for free.
const ticketPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
ticketId,
paymentHash: payload.payment_hash,
});
return c.json({ received: true, processed: false }, 200);
}
// Stop background checker since webhook confirmed payment
stopBackgroundChecker(ticketId);
@@ -186,15 +210,13 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
}
// Confirm all tickets in the booking
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
for (const ticket of ticketsToConfirm) {
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticket.id));
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
// Update payment status to paid
await (db as any)
.update(payments)
.set({
@@ -203,7 +225,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
paidAt: now,
updatedAt: now,
})
.where(eq((payments as any).ticketId, ticket.id));
.where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending')));
console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`);
}