mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: generate a session secret on first start when none is configured
This commit is contained in:
+3
-1
@@ -109,7 +109,9 @@ JMAP_SERVER_URL=https://your-jmap-server.com
|
||||
# =============================================================================
|
||||
|
||||
# Secret key for encrypting "Remember me" sessions and settings sync data.
|
||||
# Required for both "Remember me" and settings sync features.
|
||||
# Required for both "Remember me" and settings sync features. Left unset,
|
||||
# one is generated on first start and kept in the admin state dir
|
||||
# (data/admin-state), so keep that dir on a persistent volume.
|
||||
# Generate with: openssl rand -base64 32
|
||||
# SESSION_SECRET=your-secret-key-here
|
||||
# Alternatively, you can specify the path to a file containing the session secret.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { parseDomainBranding } from '@/lib/admin/domain-branding';
|
||||
import { initAdminPassword, isAdminEnabled } from '@/lib/admin/password';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
|
||||
// Strings that count as "no real secret configured" - used so the dashboard
|
||||
// can warn about a placeholder session secret without us ever returning the
|
||||
@@ -35,7 +36,9 @@ export async function GET(request: NextRequest) {
|
||||
if (SENSITIVE_CONFIG_KEYS.has(key)) {
|
||||
const v = entry.value;
|
||||
const hasValue =
|
||||
typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v);
|
||||
(typeof v === 'string' && v.length > 0 && !SENSITIVE_PLACEHOLDERS.has(v)) ||
|
||||
// Nothing configured, but the secret generated on first start is there.
|
||||
(key === 'sessionSecret' && entry.source === 'default' && hasSessionSecret());
|
||||
safe[key] = { source: entry.source, hasValue };
|
||||
} else {
|
||||
safe[key] = entry;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { initAdminPassword } from "./lib/admin/password";
|
||||
import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate";
|
||||
import { detectSetupState } from "./lib/setup/state";
|
||||
import { ensureSetupToken } from "./lib/setup/token";
|
||||
import { ensureSessionSecret } from "./lib/auth/session-secret";
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(`${process.cwd()}/package.json`, "utf-8")
|
||||
@@ -19,6 +20,16 @@ migrateLegacyAdminLayout()
|
||||
.then(() => initAdminPassword())
|
||||
.then(async () => {
|
||||
console.info("Admin dashboard initialized");
|
||||
try {
|
||||
if (await ensureSessionSecret()) {
|
||||
console.info(
|
||||
"Generated a session secret in the admin state dir. Keep that dir on a " +
|
||||
"persistent volume, or set SESSION_SECRET, so restarts don't sign everyone out.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to generate a session secret:", err instanceof Error ? err.message : err);
|
||||
}
|
||||
// If we're in bootstrap state (no JMAP_SERVER_URL env and no
|
||||
// setupComplete in config.json), generate/refresh the setup token and
|
||||
// print it to the logs so the operator can complete the web wizard
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// An empty config dir so no developer admin override supplies a secret.
|
||||
process.env.ADMIN_CONFIG_DIR = mkdtempSync(path.join(tmpdir(), 'bw-secret-config-'));
|
||||
|
||||
const { ensureSessionSecret, getSessionSecret, hasSessionSecret } = await import('@/lib/auth/session-secret');
|
||||
|
||||
describe('ensureSessionSecret', () => {
|
||||
let stateDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
stateDir = mkdtempSync(path.join(tmpdir(), 'bw-secret-state-'));
|
||||
vi.stubEnv('ADMIN_STATE_DIR', stateDir);
|
||||
vi.stubEnv('SESSION_SECRET', undefined);
|
||||
vi.stubEnv('SESSION_SECRET_FILE', undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('generates a secret on first start when none is configured', async () => {
|
||||
expect(hasSessionSecret()).toBe(false);
|
||||
|
||||
expect(await ensureSessionSecret()).toBe(true);
|
||||
|
||||
const secret = getSessionSecret();
|
||||
expect(Buffer.from(secret, 'base64')).toHaveLength(32);
|
||||
expect(readFileSync(path.join(stateDir, '.session-secret'), 'utf-8')).toBe(secret);
|
||||
});
|
||||
|
||||
it('keeps the generated secret across restarts', async () => {
|
||||
await ensureSessionSecret();
|
||||
const first = getSessionSecret();
|
||||
|
||||
expect(await ensureSessionSecret()).toBe(false);
|
||||
expect(getSessionSecret()).toBe(first);
|
||||
});
|
||||
|
||||
it('writes nothing when SESSION_SECRET is set', async () => {
|
||||
vi.stubEnv('SESSION_SECRET', 'from-env');
|
||||
|
||||
expect(await ensureSessionSecret()).toBe(false);
|
||||
expect(existsSync(path.join(stateDir, '.session-secret'))).toBe(false);
|
||||
expect(getSessionSecret()).toBe('from-env');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,19 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
|
||||
/** Written on first start when no secret is configured (see ensureSessionSecret). */
|
||||
const GENERATED_SECRET_FILE = '.session-secret';
|
||||
|
||||
/**
|
||||
* Resolve the session secret from any of the supported sources, in priority
|
||||
* order:
|
||||
* 1. SESSION_SECRET env var
|
||||
* 2. SESSION_SECRET_FILE-pointed file
|
||||
* 3. Admin override in config.json (set by the setup wizard)
|
||||
* 4. The secret generated on first start, in the admin state dir
|
||||
*
|
||||
* Returns an empty string when nothing is configured. Callers must treat
|
||||
* empty as "feature disabled" rather than crashing.
|
||||
@@ -28,9 +35,36 @@ export function getSessionSecret(): string {
|
||||
const fromAdminFile = readFileEnv(
|
||||
configManager.get<string>('sessionSecretFile', ''),
|
||||
);
|
||||
return fromAdminFile || "";
|
||||
if (fromAdminFile) return fromAdminFile;
|
||||
|
||||
return readFileEnv(getStatePath(GENERATED_SECRET_FILE)) || "";
|
||||
}
|
||||
|
||||
export function hasSessionSecret(): boolean {
|
||||
return getSessionSecret().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session secret when none is configured, so remember me and
|
||||
* settings sync work on installs that skip the setup wizard (JMAP_SERVER_URL
|
||||
* set in the environment). It lives in the state dir, which is always
|
||||
* writable and a volume in docker-compose.yml; without a persistent volume a
|
||||
* restart issues a new one and signs everyone out.
|
||||
*
|
||||
* Returns true when this call wrote a new secret.
|
||||
*/
|
||||
export async function ensureSessionSecret(): Promise<boolean> {
|
||||
if (hasSessionSecret()) return false;
|
||||
await ensureStateDir();
|
||||
try {
|
||||
// 'wx' so two processes starting at once can't overwrite each other's.
|
||||
await writeFile(getStatePath(GENERATED_SECRET_FILE), randomBytes(32).toString('base64'), {
|
||||
flag: 'wx',
|
||||
mode: 0o600,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user