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