fix: refuse the old admin password everywhere right after it was changed

This commit is contained in:
Linus Rath
2026-10-31 23:07:11 +01:00
parent 7a482a4d0e
commit cde02f5a48
2 changed files with 119 additions and 39 deletions
@@ -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, unknown>)[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);
});
});
+62 -39
View File
@@ -105,9 +105,32 @@ async function writeStateData(data: AdminStateData): Promise<void> {
// ─── 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<AdminStateData | null> {
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<AdminStateData> {
* - Otherwise, admin dashboard stays disabled.
*/
export async function initAdminPassword(): Promise<boolean> {
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<boolean> {
* Verify a password against the stored admin hash.
*/
export async function verifyAdminPassword(password: string): Promise<boolean> {
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<void> {
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<void> {
* 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<void
const previous = await loadState();
const revoked = Object.fromEntries(Object.entries(previous?.revokedSessions ?? {}).filter(([, until]) => until >= now));
revoked[sid] = exp;
cachedState = { ...(previous ?? freshState()), revokedSessions: revoked };
await writeStateData(cachedState);
cache.state = { ...(previous ?? freshState()), revokedSessions: revoked };
await writeStateData(cache.state);
}