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 { 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 };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue