From 1de0cc694049d1dffec56aeab33312b0eebdf56c Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Sun, 23 Aug 2026 22:42:24 +0200 Subject: [PATCH] Fix two security/data-integrity gaps found in code review - Voucher generation (wildcard + personalized) now validates every eventId against the event_ticket_settings allow-list before minting codes. Previously a direct API call bypassing the admin picker could mint a fully-uncapped, no-deadline redeemable code for any Calendar event, including non-concert ones. - Redemption now checks the Calendar event isn't DELETED (validateVoucher and redeemVoucher both). Previously a concert canceled/deleted after codes were issued stayed silently redeemable. DRAFT events remain eligible on purpose - vouchers are sometimes sent before a concert is publicly announced. Co-Authored-By: Claude Sonnet 5 --- .../tickets/admin/vouchers.admin.service.ts | 22 +++++++++++++++++++ .../tickets/public/voucher.public.service.ts | 18 ++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/models/tickets/admin/vouchers.admin.service.ts b/src/models/tickets/admin/vouchers.admin.service.ts index a10ecf6..eeb4195 100644 --- a/src/models/tickets/admin/vouchers.admin.service.ts +++ b/src/models/tickets/admin/vouchers.admin.service.ts @@ -29,6 +29,24 @@ const mapVoucherRow = (row: any): VoucherCode => ({ eligibleEventIds: [] }); +/** + * Guards the event allow-list (event_ticket_settings) at the one place + * codes actually get minted - the admin event picker already filters to + * allow-listed events, but that's cosmetic unless generation enforces the + * same rule server-side. Without this, any event_id could be passed + * directly (bypassing the picker) and get a fully-uncapped, no-deadline + * redeemable code minted for a non-concert Calendar event. + */ +const assertEventsAllowListed = async (conn: any, eventIds: number[]): Promise => { + const uniqueIds = [...new Set(eventIds)]; + const rows = await conn.query('SELECT event_id FROM event_ticket_settings WHERE event_id IN (?)', [uniqueIds]); + const allowListed = new Set(rows.map((r: any) => r.event_id)); + const missing = uniqueIds.filter(id => !allowListed.has(id)); + if (missing.length > 0) { + throw new Error(`event(s) not added to the ticket shop yet: ${missing.join(', ')}`); + } +}; + /** * Attaches eligibleEventIds to a list of voucher rows in one extra query, * rather than N+1 per code. @@ -66,6 +84,8 @@ export const generateWildcardBatch = async (input: WildcardGenerateInput, create try { await conn.beginTransaction(); + await assertEventsAllowListed(conn, input.eventIds); + const existingRows = await conn.query('SELECT code FROM voucher_codes'); const existingCodes = new Set(existingRows.map((r: any) => r.code)); @@ -115,6 +135,8 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr try { await conn.beginTransaction(); + await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds)); + const existingRows = await conn.query('SELECT code FROM voucher_codes'); const existingCodes = new Set(existingRows.map((r: any) => r.code)); diff --git a/src/models/tickets/public/voucher.public.service.ts b/src/models/tickets/public/voucher.public.service.ts index 415ca2c..4f3c5fd 100644 --- a/src/models/tickets/public/voucher.public.service.ts +++ b/src/models/tickets/public/voucher.public.service.ts @@ -18,8 +18,11 @@ const formatGermanDateTime = (date: Date): string => { /** * Builds the eligible-events list for a code: for each event it's linked * to, merges live Calendar event details with the Tickets module's own - * capacity/deadline state. Events that were deleted from the calendar since - * the code was generated are silently skipped rather than erroring. + * capacity/deadline state. Events deleted from the calendar since the code + * was generated are silently skipped rather than erroring. DRAFT events are + * deliberately still eligible - vouchers are sometimes sent out before a + * concert is publicly announced (see docs/plan-ticket-shop.md), so only + * DELETED is excluded here, not draft/unpublished status. */ export const validateVoucher = async (code: string): Promise => { let conn = await NachklangTicketsDB.getConnection(); @@ -37,7 +40,7 @@ export const validateVoucher = async (code: string): Promise voucher.max_guests) { await conn.rollback(); throw new Error(`this code allows at most ${voucher.max_guests} guests`);