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';
|
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 {
|
function isAuthed(req: FastifyRequest): boolean {
|
||||||
const expected = process.env.ADMIN_PASSWORD;
|
const expected = process.env.ADMIN_PASSWORD;
|
||||||
if (!expected) return false;
|
if (!expected) return false;
|
||||||
|
|
@ -50,8 +58,7 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
if (!requireAuth(req, reply)) return;
|
if (!requireAuth(req, reply)) return;
|
||||||
const { name, price_cents } = req.body ?? ({} as any);
|
const { name, price_cents } = req.body ?? ({} as any);
|
||||||
if (!name || !Number.isInteger(price_cents))
|
if (!name || !isValidCents(price_cents)) return reply.code(400).send({ error: 'invalid' });
|
||||||
return reply.code(400).send({ error: 'invalid' });
|
|
||||||
const info = db
|
const info = db
|
||||||
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
|
.prepare('INSERT INTO drinks (name, price_cents) VALUES (?, ?)')
|
||||||
.run(name, price_cents);
|
.run(name, price_cents);
|
||||||
|
|
@ -65,6 +72,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||||
if (!requireAuth(req, reply)) return;
|
if (!requireAuth(req, reply)) return;
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const { name, price_cents, archived } = req.body ?? {};
|
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 sets: string[] = [];
|
||||||
const args: any[] = [];
|
const args: any[] = [];
|
||||||
if (name !== undefined) { sets.push('name = ?'); args.push(name); }
|
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 name = req.body?.name?.trim();
|
||||||
const pfand_cents = req.body?.pfand_cents ?? 0;
|
const pfand_cents = req.body?.pfand_cents ?? 0;
|
||||||
if (!name) return reply.code(400).send({ error: 'name required' });
|
if (!name) return reply.code(400).send({ error: 'name required' });
|
||||||
|
if (!isValidCents(pfand_cents)) {
|
||||||
|
return reply.code(400).send({ error: 'invalid pfand_cents' });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const info = db
|
const info = db
|
||||||
.prepare('INSERT INTO bars (name, pfand_cents) VALUES (?, ?)')
|
.prepare('INSERT INTO bars (name, pfand_cents) VALUES (?, ?)')
|
||||||
|
|
@ -135,6 +148,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
|
||||||
if (!requireAuth(req, reply)) return;
|
if (!requireAuth(req, reply)) return;
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const { name, pfand_cents, drink_ids } = req.body ?? {};
|
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 {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,16 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) {
|
||||||
return config;
|
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) => {
|
app.post<{ Body: CreateTransactionRequest }>('/api/transactions', async (req, reply) => {
|
||||||
const body = req.body ?? ({} as CreateTransactionRequest);
|
const body = req.body ?? ({} as CreateTransactionRequest);
|
||||||
const { client_uuid, bar_id, crew, items } = body;
|
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') {
|
if (!client_uuid || typeof client_uuid !== 'string') {
|
||||||
return reply.code(400).send({ error: 'client_uuid required' });
|
return reply.code(400).send({ error: 'client_uuid required' });
|
||||||
|
|
@ -51,6 +57,19 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) {
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
return reply.code(400).send({ error: 'items required' });
|
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) {
|
if (items.length === 0 && pfand_returns === 0) {
|
||||||
return reply.code(400).send({ error: 'empty transaction' });
|
return reply.code(400).send({ error: 'empty transaction' });
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +106,12 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) {
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const item of items) {
|
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' });
|
return reply.code(400).send({ error: 'invalid item' });
|
||||||
}
|
}
|
||||||
const drink = drinkStmt.get(bar_id, item.drink_id) as
|
const drink = drinkStmt.get(bar_id, item.drink_id) as
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue