fix: write the admin config, password hash and setup token readable only by the app's own user

This commit is contained in:
Linus Rath
2026-10-26 11:48:38 +01:00
parent 3036769645
commit 30cb676fad
4 changed files with 69 additions and 3 deletions
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'node:path';
// config.json carries the session secret and the OAuth client secrets,
// admin.json the admin password hash, .setup-token the token that lets
// anyone finish the setup wizard. With the default umask they came out
// world-readable, so on a bind mount or a bare-metal install every local
// user could read the key behind every session cookie. They are written
// owner-only now, like the generated .session-secret.
// Hoisted so the dirs are set before any admin module loads.
const dirs = await vi.hoisted(async () => {
const { mkdtempSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const config = mkdtempSync(join(tmpdir(), 'bulwark-modes-'));
process.env.ADMIN_CONFIG_DIR = config;
process.env.ADMIN_STATE_DIR = join(config, 'state');
return { config, state: join(config, 'state') };
});
const writeFile = vi.hoisted(() => vi.fn());
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
writeFile.mockImplementation(actual.writeFile);
return { ...actual, default: { ...actual, writeFile }, writeFile };
});
/** The mode the file at `target` (or its temp file) was created with. */
function modeOf(target: string): unknown {
const call = writeFile.mock.calls.find(([file]) => file === target || file === `${target}.tmp`);
expect(call, target).toBeDefined();
const options = call![2];
return typeof options === 'object' && options !== null ? (options as { mode?: number }).mode : undefined;
}
describe('files holding secrets are owner-only', () => {
beforeEach(() => {
writeFile.mockClear();
});
it('writes config.json with mode 0600', async () => {
const { configManager } = await import('@/lib/admin/config-manager');
await configManager.ensureLoaded();
await configManager.setAdminConfig({ oauthClientSecret: 'client-secret' });
expect(modeOf(path.join(dirs.config, 'config.json'))).toBe(0o600);
});
it('writes admin.json with mode 0600', async () => {
const { setInitialAdminPassword } = await import('@/lib/admin/password');
expect(await setInitialAdminPassword('correct horse battery', { allowOverwrite: true })).toBe(true);
expect(modeOf(path.join(dirs.config, 'admin.json'))).toBe(0o600);
});
it('writes the setup token with mode 0600', async () => {
const { ensureSetupToken } = await import('@/lib/setup/token');
await ensureSetupToken();
expect(modeOf(path.join(dirs.state, '.setup-token'))).toBe(0o600);
});
});
+2 -1
View File
@@ -242,7 +242,8 @@ class ConfigManager {
await ensureConfigDir();
const targetPath = getConfigPath(filename);
const tmpPath = targetPath + '.tmp';
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
// Owner-only: config.json carries the session secret and OAuth client secrets.
await writeFile(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
await rename(tmpPath, targetPath);
}
}
+2 -1
View File
@@ -90,7 +90,8 @@ async function writeConfigData(data: AdminConfigData): Promise<void> {
await ensureConfigDir();
const target = getConfigPath(ADMIN_CONFIG_FILE);
const tmp = target + '.tmp';
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
// Owner-only, like the other files that hold secrets.
await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf-8', mode: 0o600 });
await rename(tmp, target);
}
+2 -1
View File
@@ -58,7 +58,8 @@ export async function ensureSetupToken(ttlSeconds: number = DEFAULT_TTL_SECONDS)
ttlSeconds,
};
const path = getStatePath(TOKEN_FILE);
await writeFile(path, JSON.stringify(payload, null, 2), 'utf-8');
// Owner-only: whoever reads it can finish the setup wizard.
await writeFile(path, JSON.stringify(payload, null, 2), { encoding: 'utf-8', mode: 0o600 });
return token;
}