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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> => {
|
||||
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<number>(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<string>(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<string>(existingRows.map((r: any) => r.code));
|
||||
|
||||
|
||||
@@ -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<VoucherValidation | null> => {
|
||||
let conn = await NachklangTicketsDB.getConnection();
|
||||
@@ -37,7 +40,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
|
||||
for (const row of eventIdRows) {
|
||||
const eventId = row.event_id;
|
||||
const event = await EventsService.getEventById(eventId);
|
||||
if (!event) continue;
|
||||
if (!event || event.status === 'DELETED') continue;
|
||||
|
||||
const ticketState = await getEventTicketState(conn, eventId);
|
||||
eligibleEvents.push({
|
||||
@@ -115,6 +118,15 @@ export const redeemVoucher = async (code: string, request: RedeemRequest): Promi
|
||||
return {status: 'INVALID_EVENT'};
|
||||
}
|
||||
|
||||
// A concert can be canceled/deleted from the calendar after codes were
|
||||
// issued - without this, such a code would stay silently redeemable.
|
||||
// DRAFT is deliberately still allowed (see validateVoucher's comment).
|
||||
const event = await EventsService.getEventById(request.eventId);
|
||||
if (!event || event.status === 'DELETED') {
|
||||
await conn.rollback();
|
||||
return {status: 'INVALID_EVENT'};
|
||||
}
|
||||
|
||||
if (request.guests.length > voucher.max_guests) {
|
||||
await conn.rollback();
|
||||
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
|
||||
|
||||
Reference in New Issue
Block a user