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:
parent
7fc4c1912c
commit
da8a815677
2 changed files with 29 additions and 8 deletions
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue