From 466fa7f97463bb9514dff721ec63fa4b455e1419 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:15:42 +0100 Subject: [PATCH] fix: keep the desktop app's session secret and admin password out of its server's environment --- lib/admin/password.ts | 3 +- lib/auth/session-secret.ts | 4 ++ lib/desktop/__tests__/server-secrets.test.ts | 57 ++++++++++++++++++++ lib/desktop/__tests__/trust-flag.test.ts | 14 +++++ lib/desktop/server-secrets.ts | 20 +++++++ 5 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 lib/desktop/__tests__/server-secrets.test.ts create mode 100644 lib/desktop/server-secrets.ts diff --git a/lib/admin/password.ts b/lib/admin/password.ts index bae73d0b5..a2bfdce13 100644 --- a/lib/admin/password.ts +++ b/lib/admin/password.ts @@ -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 { 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; diff --git a/lib/auth/session-secret.ts b/lib/auth/session-secret.ts index cd3b6cee7..d8faeea3c 100644 --- a/lib/auth/session-secret.ts +++ b/lib/auth/session-secret.ts @@ -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; diff --git a/lib/desktop/__tests__/server-secrets.test.ts b/lib/desktop/__tests__/server-secrets.test.ts new file mode 100644 index 000000000..ff7016879 --- /dev/null +++ b/lib/desktop/__tests__/server-secrets.test.ts @@ -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; +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(''); + }); +}); diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts index 0edd41532..ad56705eb 100644 --- a/lib/desktop/__tests__/trust-flag.test.ts +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -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'); diff --git a/lib/desktop/server-secrets.ts b/lib/desktop/server-secrets.ts new file mode 100644 index 000000000..ee8e76189 --- /dev/null +++ b/lib/desktop/server-secrets.ts @@ -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; + return holder[Symbol.for('bulwark.desktop.serverSecrets')]?.[name] ?? ''; +}