admin: session cookie is a random per-login token, not the password itself

The session cookie value was the admin password, replayed on every
request — one sniffed request on the LAN yields the actual shared
secret, not just a session, and logout only cleared the browser's
copy since the value (the password) stays valid forever.

Mint a random token on successful login, hold valid tokens in an
in-memory Set, set that as the cookie, and delete it from the set on
logout — logout now actually revokes the session. A server restart
naturally invalidates all sessions too (fine for this single-process
deploy).

Also compare the login password with a constant-time digest
comparison instead of ===, hygiene rather than a practical fix given
the existing shared-password/no-rate-limit threat model, but a small
change while touching this code.
This commit is contained in:
iris 2026-07-29 20:19:50 +02:00
commit da8a815677
2 changed files with 29 additions and 8 deletions

View file

@ -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<string>();
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 };
});

View file

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