mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: write the admin config, password hash and setup token readable only by the app's own user
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user