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:
parent
e52a0469c8
commit
76a1b51597
2 changed files with 44 additions and 4 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue