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: []
|
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,
|
* Attaches eligibleEventIds to a list of voucher rows in one extra query,
|
||||||
* rather than N+1 per code.
|
* rather than N+1 per code.
|
||||||
@@ -66,6 +84,8 @@ export const generateWildcardBatch = async (input: WildcardGenerateInput, create
|
|||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
await assertEventsAllowListed(conn, input.eventIds);
|
||||||
|
|
||||||
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
||||||
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
|
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
|
||||||
|
|
||||||
@@ -115,6 +135,8 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
|
|||||||
try {
|
try {
|
||||||
await conn.beginTransaction();
|
await conn.beginTransaction();
|
||||||
|
|
||||||
|
await assertEventsAllowListed(conn, rows.flatMap(r => r.eventIds));
|
||||||
|
|
||||||
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
const existingRows = await conn.query('SELECT code FROM voucher_codes');
|
||||||
const existingCodes = new Set<string>(existingRows.map((r: any) => r.code));
|
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
|
* 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
|
* to, merges live Calendar event details with the Tickets module's own
|
||||||
* capacity/deadline state. Events that were deleted from the calendar since
|
* capacity/deadline state. Events deleted from the calendar since the code
|
||||||
* the code was generated are silently skipped rather than erroring.
|
* 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> => {
|
export const validateVoucher = async (code: string): Promise<VoucherValidation | null> => {
|
||||||
let conn = await NachklangTicketsDB.getConnection();
|
let conn = await NachklangTicketsDB.getConnection();
|
||||||
@@ -37,7 +40,7 @@ export const validateVoucher = async (code: string): Promise<VoucherValidation |
|
|||||||
for (const row of eventIdRows) {
|
for (const row of eventIdRows) {
|
||||||
const eventId = row.event_id;
|
const eventId = row.event_id;
|
||||||
const event = await EventsService.getEventById(eventId);
|
const event = await EventsService.getEventById(eventId);
|
||||||
if (!event) continue;
|
if (!event || event.status === 'DELETED') continue;
|
||||||
|
|
||||||
const ticketState = await getEventTicketState(conn, eventId);
|
const ticketState = await getEventTicketState(conn, eventId);
|
||||||
eligibleEvents.push({
|
eligibleEvents.push({
|
||||||
@@ -115,6 +118,15 @@ export const redeemVoucher = async (code: string, request: RedeemRequest): Promi
|
|||||||
return {status: 'INVALID_EVENT'};
|
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) {
|
if (request.guests.length > voucher.max_guests) {
|
||||||
await conn.rollback();
|
await conn.rollback();
|
||||||
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
|
throw new Error(`this code allows at most ${voucher.max_guests} guests`);
|
||||||
|
|||||||
Reference in New Issue
Block a user