fix: keep the desktop app's session secret and admin password out of its server's environment

This commit is contained in:
Linus Rath
2026-11-04 03:15:42 +01:00
parent a9e33ec2d6
commit 466fa7f974
5 changed files with 97 additions and 1 deletions
+2 -1
View File
@@ -6,6 +6,7 @@ import { assertWritable, isConfigReadOnly } from './paths';
import { adminConfigDocs, adminStateDocs, isStoreUnavailable } from './admin-store';
import type { AdminConfigData, AdminStateData } from './types';
import { readFileEnv } from '../read-file-env';
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
const SCRYPT_KEYLEN = 64;
const SCRYPT_COST = 16384; // 2^14
@@ -269,7 +270,7 @@ export async function initAdminPassword(): Promise<boolean> {
return true;
}
const envPassword = process.env.ADMIN_PASSWORD;
const envPassword = desktopServerSecret('adminPassword') || process.env.ADMIN_PASSWORD;
if (!envPassword) {
if (!cache.initialized) logger.info('Admin dashboard disabled (no ADMIN_PASSWORD set)');
cache.initialized = true;
+4
View File
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { configManager } from '@/lib/admin/config-manager';
import { desktopServerSecret } from '@/lib/desktop/server-secrets';
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { readFileEnv } from '@/lib/read-file-env';
import { getStore, isSharedMode } from '@/lib/shared-store';
@@ -25,6 +26,9 @@ function sharedSecret(): string {
/** A secret the operator set: env, file, or the setup wizard's config. */
function configuredSessionSecret(): string {
const fromShell = desktopServerSecret('sessionSecret');
if (fromShell) return fromShell;
const fromEnv = process.env.SESSION_SECRET;
if (fromEnv) return fromEnv;
@@ -0,0 +1,57 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The desktop app's server gets its secrets by message, so they stay out of its
* environment block. No other build looks there.
*/
vi.mock('@/lib/admin/config-manager', () => ({
configManager: { get: (_key: string, fallback: unknown) => fallback },
}));
vi.mock('@/lib/shared-store', () => ({ isSharedMode: () => false, getStore: () => ({}) }));
vi.mock('@/lib/admin/paths', () => ({ ensureStateDir: async () => {}, getStatePath: () => 'C:/nowhere/.session-secret' }));
const KEY = Symbol.for('bulwark.desktop.serverSecrets');
const holder = globalThis as unknown as Record<symbol, unknown>;
const FROM_SHELL = 'from-the-shell-from-the-shell-from-the-shell';
async function sessionSecret() {
vi.resetModules();
return (await import('@/lib/auth/session-secret')).getSessionSecret();
}
beforeEach(() => {
holder[KEY] = { sessionSecret: FROM_SHELL, adminPassword: 'admin-from-the-shell' };
});
afterEach(() => {
vi.unstubAllEnvs();
delete holder[KEY];
});
describe('the session secret', () => {
it('comes from the shell in the desktop build, with nothing in the environment', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
vi.stubEnv('SESSION_SECRET', '');
expect(await sessionSecret()).toBe(FROM_SHELL);
});
it('never from there in a server build', async () => {
vi.stubEnv('SESSION_SECRET', '');
expect(await sessionSecret()).toBe('');
vi.stubEnv('SESSION_SECRET', 'from-the-environment');
expect(await sessionSecret()).toBe('from-the-environment');
});
});
describe('the admin password', () => {
it('is only readable from the shell in the desktop build', async () => {
const { desktopServerSecret } = await import('../server-secrets');
expect(desktopServerSecret('adminPassword')).toBe('');
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
expect(desktopServerSecret('adminPassword')).toBe('admin-from-the-shell');
delete holder[KEY];
expect(desktopServerSecret('adminPassword')).toBe('');
});
});
+14
View File
@@ -134,6 +134,20 @@ describe('the flag is fixed at build time', () => {
expect(await trustCheckOfBuild('1')).toContain(DESKTOP_TRUST_MARKER);
});
it('the secrets from the shell are read in the desktop build only', async () => {
const compile = async (flag: string) => (await build({
entryPoints: [path.join(ROOT, 'lib/desktop/server-secrets.ts')],
bundle: true,
write: false,
minify: true,
format: 'esm',
platform: 'node',
define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(flag) },
})).outputFiles[0].text;
expect(await compile('')).not.toContain('bulwark.desktop.serverSecrets');
expect(await compile('1')).toContain('bulwark.desktop.serverSecrets');
});
it('no image build sets it', () => {
for (const file of ['Dockerfile', 'Dockerfile.lite']) {
expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP');
+20
View File
@@ -0,0 +1,20 @@
/**
* The session secret and local admin password of the desktop app's own server.
*
* The Electron main process sends them by message, and src/server/host.ts in the
* desktop repo puts them on globalThis, which all of Next's bundles share. They stay
* out of the environment block, where a child process would inherit them.
*
* Every other build reads its secrets the usual way, and this compiles to nothing.
*/
interface ShellSecrets {
sessionSecret?: string;
adminPassword?: string;
}
export function desktopServerSecret(name: keyof ShellSecrets): string {
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP !== '1') return '';
const holder = globalThis as unknown as Record<symbol, ShellSecrets | undefined>;
return holder[Symbol.for('bulwark.desktop.serverSecrets')]?.[name] ?? '';
}