diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index dc93318..f2c7de2 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -1,21 +1,36 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { DB } from '../db.js'; import { businessDay, formatLocal, parseDbTime } from '../time.js'; +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; 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. +// bar's pricing. 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; } +// Random per-login session tokens, not the password itself — so a sniffed +// cookie only yields one revocable session (not the shared secret), and +// logout actually invalidates it. In-memory: a restart naturally logs +// everyone out too, which is fine here (single-process deploy). +const validSessions = new Set(); + function isAuthed(req: FastifyRequest): boolean { - const expected = process.env.ADMIN_PASSWORD; - if (!expected) return false; - return req.cookies?.[SESSION_COOKIE] === expected; + const token = req.cookies?.[SESSION_COOKIE]; + return !!token && validSessions.has(token); +} + +// Constant-time comparison for the admin password — hashed first so a +// length mismatch doesn't throw (timingSafeEqual requires equal-length +// buffers). +function safeEqual(a: string, b: string): boolean { + const ah = createHash('sha256').update(a).digest(); + const bh = createHash('sha256').update(b).digest(); + return timingSafeEqual(ah, bh); } function requireAuth(req: FastifyRequest, reply: FastifyReply): boolean { @@ -28,8 +43,12 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => { const expected = process.env.ADMIN_PASSWORD; if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' }); - if (req.body?.password !== expected) return reply.code(401).send({ error: 'wrong password' }); - reply.setCookie(SESSION_COOKIE, expected, { + if (!safeEqual(req.body?.password ?? '', expected)) { + return reply.code(401).send({ error: 'wrong password' }); + } + const token = randomBytes(32).toString('hex'); + validSessions.add(token); + reply.setCookie(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', @@ -38,7 +57,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) { return { ok: true }; }); - app.post('/admin/logout', async (_req, reply) => { + app.post('/admin/logout', async (req, reply) => { + const token = req.cookies?.[SESSION_COOKIE]; + if (token) validSessions.delete(token); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); diff --git a/server/src/routes/public.ts b/server/src/routes/public.ts index 2e82a51..d8c039a 100644 --- a/server/src/routes/public.ts +++ b/server/src/routes/public.ts @@ -39,7 +39,7 @@ export function registerPublicRoutes(app: FastifyInstance, db: DB) { // 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. + // an absurd (or precision-losing) total. const MAX_QTY_PER_LINE = 999; const MAX_PFAND_RETURNS = 999; const MAX_ITEMS_PER_TRANSACTION = 100;