admin: expire sessions server-side, mask unconfigured-password 500

Two low-severity findings from the #47 review round (#52):

1. validSessions was a bare Set<string> — the 7-day cookie maxAge was a
   browser-side hint only, so a token stayed valid forever server-side
   until an explicit /admin/logout or a process restart. Now a
   Map<token, expiry>, checked and pruned lazily on lookup, with an
   opportunistic full sweep on login so an abandoned session doesn't
   linger in memory indefinitely either.

2. /admin/login on a misconfigured deploy (ADMIN_PASSWORD unset)
   returned the literal string "ADMIN_PASSWORD not set" to an
   unauthenticated caller, bypassing the generic-500 masking every other
   500 in the app goes through (see index.ts's error handler) — minor
   recon value for anyone probing. Now logged server-side and masked
   like any other internal error.
This commit is contained in:
iris 2026-07-31 01:20:00 +02:00 committed by mara
commit fd04564602

View file

@ -18,11 +18,34 @@ function isValidCents(v: unknown): v is number {
// 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>();
//
// Maps each token to its own expiry (ms epoch) so the 7-day cookie maxAge
// is honoured server-side too, not just as a hint the browser can ignore —
// previously a token stayed valid indefinitely until an explicit
// /admin/logout or a process restart.
const SESSION_MAXAGE_SECONDS = 60 * 60 * 24 * 7;
const validSessions = new Map<string, number>();
function isAuthed(req: FastifyRequest): boolean {
const token = req.cookies?.[SESSION_COOKIE];
return !!token && validSessions.has(token);
if (!token) return false;
const expires = validSessions.get(token);
if (expires === undefined) return false;
if (Date.now() > expires) {
validSessions.delete(token);
return false;
}
return true;
}
// Opportunistic sweep of expired tokens — called on login (a rare path
// relative to isAuthed, which runs on every authed request) so an
// abandoned session's memory doesn't linger forever between lookups.
function pruneExpiredSessions(): void {
const now = Date.now();
for (const [token, expires] of validSessions) {
if (now > expires) validSessions.delete(token);
}
}
// Constant-time comparison for the admin password — hashed first so a
@ -255,17 +278,25 @@ function computeStats(db: DB) {
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 sendProblem(req, reply, 500, 'Internal Server Error', 'ADMIN_PASSWORD not set');
if (!expected) {
// Masked like every other 500 (see index.ts's error handler) — the
// literal "ADMIN_PASSWORD not set" used to go straight to an
// unauthenticated caller, confirming a misconfigured deploy (no
// password will ever work) to anyone probing.
req.log.error('ADMIN_PASSWORD not set');
return sendProblem(req, reply, 500, 'Internal Server Error', 'internal error');
}
if (!safeEqual(req.body?.password ?? '', expected)) {
return sendProblem(req, reply, 401, 'Unauthorized', 'wrong password');
}
pruneExpiredSessions();
const token = randomBytes(32).toString('hex');
validSessions.add(token);
validSessions.set(token, Date.now() + SESSION_MAXAGE_SECONDS * 1000);
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
maxAge: SESSION_MAXAGE_SECONDS,
});
return { ok: true };
});