validate money-adjacent inputs: bound qty/pfand_returns, validate price_cents/pfand_cents everywhere

- POST /api/transactions: pfand_returns is now rejected with 400 if
  non-integer or negative instead of silently coerced via
  Math.max(0, Math.floor(x)) (which turned a non-numeric value into
  NaN and slipped past the empty-transaction guard). Both
  pfand_returns and per-line qty are capped at a generous but bounded
  999; items.length capped at 100.
- Admin routes: price_cents/pfand_cents are validated (integer,
  0..100000 EUR) on all four write paths — POST/PATCH drinks and
  POST/PATCH bars. Previously only POST drinks checked
  Number.isInteger with no bound; the other three had no check at
  all, so a bad value (float, string, negative) could reach SQLite
  directly.
This commit is contained in:
iris 2026-07-29 20:18:49 +02:00 committed by mara
commit 76a1b51597
2 changed files with 44 additions and 4 deletions

View file

@ -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) {

View file

@ -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