From cde02f5a483df010a4b4de5b4dbb05ae848428a6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 23:07:11 +0100 Subject: [PATCH] fix: refuse the old admin password everywhere right after it was changed --- .../admin-password-module-copies.test.ts | 57 ++++++++++ lib/admin/password.ts | 101 +++++++++++------- 2 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 lib/__tests__/admin-password-module-copies.test.ts diff --git a/lib/__tests__/admin-password-module-copies.test.ts b/lib/__tests__/admin-password-module-copies.test.ts new file mode 100644 index 000000000..baedff8e8 --- /dev/null +++ b/lib/__tests__/admin-password-module-copies.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Next bundles lib/admin/password.ts once per entry: the sign-in route and +// the change-password route each have their own copy in one process. Each +// copy used to keep its own cache of admin.json, so after a password change +// the sign-in route went on accepting the old password until a restart. + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'bw-admin-copies-')); + vi.stubEnv('ADMIN_CONFIG_DIR', dir); + vi.stubEnv('ADMIN_STATE_DIR', path.join(dir, 'state')); + // Every copy made below starts from nothing, as after a restart. + delete (globalThis as Record)[Symbol.for('bulwark.admin.password')]; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +/** A module copy of its own, as another bundle entry has. */ +async function moduleCopy() { + vi.resetModules(); + return import('@/lib/admin/password'); +} + +describe('admin password across module copies', () => { + it('refuses the old password in every copy after a change in one', async () => { + const settings = await moduleCopy(); + expect(await settings.setInitialAdminPassword('old-password-1')).toBe(true); + const signIn = await moduleCopy(); + expect(await signIn.verifyAdminPassword('old-password-1')).toBe(true); + const generation = await signIn.getAdminSessionGeneration(); + + expect(await settings.changeAdminPassword('old-password-1', 'new-password-2')).toBe(true); + + expect(await signIn.verifyAdminPassword('old-password-1')).toBe(false); + expect(await signIn.verifyAdminPassword('new-password-2')).toBe(true); + expect(await signIn.getAdminSessionGeneration()).toBe(generation + 1); + }); + + it('sees a sign-out made through another copy', async () => { + const signIn = await moduleCopy(); + expect(await signIn.isAdminSessionRevoked('sid-1')).toBe(false); + const signOut = await moduleCopy(); + await signOut.revokeAdminSession('sid-1', Math.floor(Date.now() / 1000) + 3600); + expect(await signIn.isAdminSessionRevoked('sid-1')).toBe(true); + }); +}); diff --git a/lib/admin/password.ts b/lib/admin/password.ts index e0dd8baa6..89fa19550 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -105,9 +105,32 @@ async function writeStateData(data: AdminStateData): Promise { // ─── Cache & init ─────────────────────────────────────────────────────────── -let cachedConfig: AdminConfigData | null = null; -let cachedState: AdminStateData | null = null; -let initialized = false; +/** + * What this process knows of admin.json and admin-state.json. On globalThis: + * Next bundles this module once per entry (each route bundle, the + * instrumentation), and a copy with its own cache kept accepting the old + * password after a password change made through another one, until a + * restart. + */ +interface AdminCache { + config: AdminConfigData | null; + state: AdminStateData | null; + initialized: boolean; +} + +const CACHE_KEY = Symbol.for('bulwark.admin.password'); +const cache: AdminCache = ((globalThis as typeof globalThis & { [CACHE_KEY]?: AdminCache })[CACHE_KEY] ??= { + config: null, + state: null, + initialized: false, +}); + +/** Forget what this process read, as a restart would (tests). */ +export function resetAdminCacheForTests(): void { + cache.config = null; + cache.state = null; + cache.initialized = false; +} function freshState(): AdminStateData { const now = new Date().toISOString(); @@ -115,8 +138,8 @@ function freshState(): AdminStateData { } async function loadState(): Promise { - if (!cachedState) cachedState = await readStateData(); - return cachedState; + if (!cache.state) cache.state = await readStateData(); + return cache.state; } /** @@ -135,38 +158,38 @@ async function stateForNewPassword(): Promise { * - Otherwise, admin dashboard stays disabled. */ export async function initAdminPassword(): Promise { - if (initialized) return cachedConfig !== null; + if (cache.initialized) return cache.config !== null; const existingConfig = await readConfigData(); if (existingConfig) { - cachedConfig = existingConfig; - cachedState = (await readStateData()) ?? freshState(); + cache.config = existingConfig; + cache.state = (await readStateData()) ?? freshState(); if (!(await readStateData())) { // No state file yet (fresh install or migration); create it. try { - await writeStateData(cachedState); + await writeStateData(cache.state); } catch { /* state dir may not be writable yet during early boot probes */ } } - initialized = true; + cache.initialized = true; logger.info('Admin dashboard enabled (password loaded from admin.json)'); return true; } const envPassword = process.env.ADMIN_PASSWORD; if (!envPassword) { - initialized = true; + cache.initialized = true; logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)'); return false; } const hash = isHashed(envPassword) ? envPassword : await hashPassword(envPassword); - cachedConfig = { passwordHash: hash, passwordHashFile: undefined }; - cachedState = await stateForNewPassword(); - await writeConfigData(cachedConfig); - await writeStateData(cachedState); - initialized = true; + cache.config = { passwordHash: hash, passwordHashFile: undefined }; + cache.state = await stateForNewPassword(); + await writeConfigData(cache.config); + await writeStateData(cache.state); + cache.initialized = true; if (isHashed(envPassword)) { logger.info('Admin password hash saved to admin.json from environment variable'); } else { @@ -179,12 +202,12 @@ export async function initAdminPassword(): Promise { * Verify a password against the stored admin hash. */ export async function verifyAdminPassword(password: string): Promise { - if (!cachedConfig) cachedConfig = await readConfigData(); - if (!cachedConfig) return false; - if (cachedConfig.passwordHash) { - return verifyPassword(password, cachedConfig.passwordHash); - } else if (cachedConfig.passwordHashFile) { - let passwordHash = readFileEnv(cachedConfig.passwordHashFile); + if (!cache.config) cache.config = await readConfigData(); + if (!cache.config) return false; + if (cache.config.passwordHash) { + return verifyPassword(password, cache.config.passwordHash); + } else if (cache.config.passwordHashFile) { + let passwordHash = readFileEnv(cache.config.passwordHashFile); if (passwordHash) { return verifyPassword(password, passwordHash); } @@ -205,15 +228,15 @@ export async function changeAdminPassword(currentPassword: string, newPassword: const hash = await hashPassword(newPassword); const next: AdminConfigData = { passwordHash: hash, passwordHashFile: undefined }; await writeConfigData(next); - cachedConfig = next; + cache.config = next; const previous = await loadState(); - cachedState = { + cache.state = { ...(previous ?? freshState()), passwordChangedAt: new Date().toISOString(), sessionGeneration: (previous?.sessionGeneration ?? 0) + 1, }; - await writeStateData(cachedState); + await writeStateData(cache.state); return true; } @@ -235,11 +258,11 @@ export async function setInitialAdminPassword( const existing = await readConfigData(); if (existing && !options.allowOverwrite) return false; const hash = await hashPassword(newPassword); - cachedConfig = { passwordHash: hash, passwordHashFile: undefined }; - cachedState = await stateForNewPassword(); - await writeConfigData(cachedConfig); - await writeStateData(cachedState); - initialized = true; + cache.config = { passwordHash: hash, passwordHashFile: undefined }; + cache.state = await stateForNewPassword(); + await writeConfigData(cache.config); + await writeStateData(cache.state); + cache.initialized = true; return true; } @@ -247,13 +270,13 @@ export async function setInitialAdminPassword( * Update the last login timestamp. */ export async function updateLastLogin(): Promise { - if (!cachedConfig) return; - cachedState = { - ...(cachedState ?? freshState()), + if (!cache.config) return; + cache.state = { + ...(cache.state ?? freshState()), lastLogin: new Date().toISOString(), }; try { - await writeStateData(cachedState); + await writeStateData(cache.state); } catch (error) { logger.warn('Failed to update admin last-login state', { error: error instanceof Error ? error.message : 'Unknown error', @@ -265,15 +288,15 @@ export async function updateLastLogin(): Promise { * Check if admin dashboard is enabled (has a password configured). */ export function isAdminEnabled(): boolean { - return cachedConfig !== null; + return cache.config !== null; } /** * Get admin metadata (without the hash). */ export function getAdminMeta(): AdminStateData | null { - if (!cachedConfig) return null; - return cachedState ?? freshState(); + if (!cache.config) return null; + return cache.state ?? freshState(); } // ─── Admin session sign-out ───────────────────────────────────────────────── @@ -297,6 +320,6 @@ export async function revokeAdminSession(sid: string, exp: number): Promise until >= now)); revoked[sid] = exp; - cachedState = { ...(previous ?? freshState()), revokedSessions: revoked }; - await writeStateData(cachedState); + cache.state = { ...(previous ?? freshState()), revokedSessions: revoked }; + await writeStateData(cache.state); }