diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index e57a617..c092acd 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -4,6 +4,14 @@ import { businessDay, formatLocal, parseDbTime } from '../time.js'; const SESSION_COOKIE = 'wutz_admin'; +// Generous ceiling for a single drink price or deposit amount — bounds a +// typo (or a `2.5`/`"abc"` slipping past a missing check) from wrecking a +// bar's pricing. See wutzcalc#12. +const MAX_CENTS = 100_000_00; // 100,000 € +function isValidCents(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_CENTS; +} + function isAuthed(req: FastifyRequest): boolean { const expected = process.env.ADMIN_PASSWORD; if (!expected) return false; @@ -50,8 +58,7 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { async (req, reply) => { if (!requireAuth(req, reply)) return; const { name, price_cents } = req.body ?? ({} as any); - if (!name || !Number.isInteger(price_cents)) - return reply.code(400).send({ error: 'invalid' }); + if (!name || !isValidCents(price_cents)) return reply.code(400).send({ error: 'invalid' }); const info = db .prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)') .run(name, price_cents); @@ -65,6 +72,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const { name, price_cents, archived } = req.body ?? {}; + if (price_cents !== undefined && !isValidCents(price_cents)) { + return reply.code(400).send({ error: 'invalid price_cents' }); + } const sets: string[] = []; const args: any[] = []; if (name !== undefined) { sets.push('name = ?'); args.push(name); } @@ -115,6 +125,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { const name = req.body?.name?.trim(); const pfand_cents = req.body?.pfand_cents ?? 0; if (!name) return reply.code(400).send({ error: 'name required' }); + if (!isValidCents(pfand_cents)) { + return reply.code(400).send({ error: 'invalid pfand_cents' }); + } try { const info = db .prepare('INSERT INTO bars (name, pfand_cents) VALUES (?, ?)') @@ -135,6 +148,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { if (!requireAuth(req, reply)) return; const id = Number(req.params.id); const { name, pfand_cents, drink_ids } = req.body ?? {}; + if (pfand_cents !== undefined && !isValidCents(pfand_cents)) { + return reply.code(400).send({ error: 'invalid pfand_cents' }); + } try { db.transaction(() => { if (name !== undefined) { diff --git a/server/src/routes/public.ts b/server/src/routes/public.ts index 96a4e53..b574116 100644 --- a/server/src/routes/public.ts +++ b/server/src/routes/public.ts @@ -37,10 +37,16 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { return config; }); + // Sane ceilings for money-adjacent quantities — generous for any real + // order/return, but bounded so a stuck button or a bad request can't post + // an absurd (or precision-losing) total. See wutzcalc#12. + const MAX_QTY_PER_LINE = 999; + const MAX_PFAND_RETURNS = 999; + const MAX_ITEMS_PER_TRANSACTION = 100; + app.post<{ Body: CreateTransactionRequest }>('/api/transactions', async (req, reply) => { const body = req.body ?? ({} as CreateTransactionRequest); const { client_uuid, bar_id, crew, items } = body; - const pfand_returns = Math.max(0, Math.floor(body.pfand_returns ?? 0)); if (!client_uuid || typeof client_uuid !== 'string') { return reply.code(400).send({ error: 'client_uuid required' }); @@ -51,6 +57,19 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { if (!Array.isArray(items)) { return reply.code(400).send({ error: 'items required' }); } + if (items.length > MAX_ITEMS_PER_TRANSACTION) { + return reply.code(400).send({ error: 'too many items' }); + } + + const rawPfandReturns = body.pfand_returns ?? 0; + if (!Number.isInteger(rawPfandReturns) || rawPfandReturns < 0) { + return reply.code(400).send({ error: 'pfand_returns must be a non-negative integer' }); + } + if (rawPfandReturns > MAX_PFAND_RETURNS) { + return reply.code(400).send({ error: 'pfand_returns too large' }); + } + const pfand_returns = rawPfandReturns; + if (items.length === 0 && pfand_returns === 0) { return reply.code(400).send({ error: 'empty transaction' }); } @@ -87,7 +106,12 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { }> = []; for (const item of items) { - if (!Number.isInteger(item.drink_id) || !Number.isInteger(item.qty) || item.qty <= 0) { + if ( + !Number.isInteger(item.drink_id) || + !Number.isInteger(item.qty) || + item.qty <= 0 || + item.qty > MAX_QTY_PER_LINE + ) { return reply.code(400).send({ error: 'invalid item' }); } const drink = drinkStmt.get(bar_id, item.drink_id) as