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 { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import type { DB } from '../db.js'; import type { DB } from '../db.js';
import { businessDay, formatLocal, parseDbTime } from '../time.js'; import { businessDay, formatLocal, parseDbTime } from '../time.js';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
const SESSION_COOKIE = 'wutz_admin'; const SESSION_COOKIE = 'wutz_admin';
// Generous ceiling for a single drink price or deposit amount — bounds a // 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 // 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 € const MAX_CENTS = 100_000_00; // 100,000 €
function isValidCents(v: unknown): v is number { function isValidCents(v: unknown): v is number {
return typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_CENTS; 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 { function isAuthed(req: FastifyRequest): boolean {
const expected = process.env.ADMIN_PASSWORD; const token = req.cookies?.[SESSION_COOKIE];
if (!expected) return false; return !!token && validSessions.has(token);
return req.cookies?.[SESSION_COOKIE] === expected; }
// 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 { 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) => { app.post<{ Body: { password?: string } }>('/admin/login', async (req, reply) => {
const expected = process.env.ADMIN_PASSWORD; const expected = process.env.ADMIN_PASSWORD;
if (!expected) return reply.code(500).send({ error: 'ADMIN_PASSWORD not set' }); 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' }); if (!safeEqual(req.body?.password ?? '', expected)) {
reply.setCookie(SESSION_COOKIE, expected, { return reply.code(401).send({ error: 'wrong password' });
}
const token = randomBytes(32).toString('hex');
validSessions.add(token);
reply.setCookie(SESSION_COOKIE, token, {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
@ -38,7 +57,9 @@ export function registerAdminRoutes(app: FastifyInstance, db: DB) {
return { ok: true }; 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: '/' }); reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true }; 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 // 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 // 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_QTY_PER_LINE = 999;
const MAX_PFAND_RETURNS = 999; const MAX_PFAND_RETURNS = 999;
const MAX_ITEMS_PER_TRANSACTION = 100; const MAX_ITEMS_PER_TRANSACTION = 100;