diff --git a/.env.example b/.env.example index b6767333b..2a758f1dd 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,12 @@ JMAP_SERVER_URL=https://your-jmap-server.com # error than a mid-request EROFS). Pair with `:ro` on the config-volume mount. # ADMIN_CONFIG_READONLY=true # +# Optional existing PEM PKCS#8 Ed25519 private key used to sign managed plugin +# bundles. Bulwark reads this exact path without modifying it. Use a runtime +# secret mount such as a systemd credential, Docker secret, or Kubernetes Secret. +# When unset, Bulwark loads or creates ADMIN_CONFIG_DIR/plugin-signing.key. +# PLUGIN_SIGNING_KEY_FILE=/run/secrets/plugin-signing-key +# # Legacy: a single dir containing both config and state. Honoured if neither # of the split variables is set. New installs should use the split vars. # ADMIN_DATA_DIR=./data/admin diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts index 94101f669..f604e56e5 100644 Binary files a/app/api/admin/auth/route.ts and b/app/api/admin/auth/route.ts differ diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index d4a971be1..cdf729636 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: @@ -12,7 +13,7 @@ import { getStalwartCredentials } from '@/lib/stalwart/credentials'; // langpair needs an explicit source language, so when the // plugin asks for "auto" we detect it locally first. // - "libretranslate" — only available when the host sets LIBRETRANSLATE_URL -// (and optionally LIBRETRANSLATE_API_KEY). Supports native +// (and optionally LIBRETRANSLATE_API_KEY or LIBRETRANSLATE_API_KEY_FILE). Supports native // source auto-detection. export const runtime = 'nodejs'; @@ -226,7 +227,9 @@ async function translateLibre( if (!endpoint) { throw new Error('LibreTranslate is not configured on this server'); } - const apiKey = process.env.LIBRETRANSLATE_API_KEY; + const apiKey = + process.env.LIBRETRANSLATE_API_KEY || + readFileEnv(process.env.LIBRETRANSLATE_API_KEY_FILE); const url = endpoint.replace(/\/+$/, '') + '/translate'; const res = await fetch(url, { method: 'POST', diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index a05c31f66..7108cdf59 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useTranslations, useLocale } from "next-intl"; +import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { LinkifiedText } from "@/components/ui/linkified-text"; @@ -493,7 +494,13 @@ export function EventModal({ const handleSave = useCallback(async () => { const trimmedTitle = title.trim(); if (!trimmedTitle || isSaving) return; - if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) return; + if (trimmedTitle.length > 500 || description.trim().length > 10000 || location.trim().length > 500) { + // This guard returned silently, so a rejected save was indistinguishable from a dead + // button. The fields also enforce their limits via maxLength, so this is a backstop — + // but if it ever fires, say so. + toast.error(t("notifications.event_error")); + return; + } const pendingAttendee = participantInputRef.current?.flush() ?? null; const effectiveAttendees = pendingAttendee ? [...attendees, pendingAttendee] : attendees; @@ -524,9 +531,10 @@ export function EventModal({ // grid and prefill are display dates), so label them with that zone. const timeZone = getEffectiveTimeZone(); + const trimmedDescription = description.trim(); + const data: Partial = { title: trimmedTitle, - description: description.trim(), start: startStr, duration, timeZone: allDay ? null : timeZone, @@ -537,6 +545,13 @@ export function EventModal({ privacy: "public", }; + // On an existing event the empty string is how a description gets cleared, but + // sending it on creation writes a `DESCRIPTION:` with nothing after it — which + // invitation e-mails then render as a "Description" heading over blank space. + if (trimmedDescription || event) { + data.description = trimmedDescription; + } + if (!event) { data.uid = generateUUID(); } @@ -632,7 +647,7 @@ export function EventModal({ } finally { setIsSaving(false); } - }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving]); + }, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, resolveContactName, event, onSave, isSaving, t]); const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => { if (!event || !userParticipantId || !onRsvp) return; @@ -1436,7 +1451,9 @@ export function EventModal({ disabled={!title.trim() || isSaving} className="w-full" > - {t("form.save")} + {/* While a save is in flight the button was disabled with no visible change, so a + slow request (mobile, VPN) looked like a dead button for the whole timeout. */} + {isSaving ? t("subscription.saving") : t("form.save")} )} diff --git a/lib/__tests__/admin-session-cookie-secure.test.ts b/lib/__tests__/admin-session-cookie-secure.test.ts new file mode 100644 index 000000000..7a97d5949 --- /dev/null +++ b/lib/__tests__/admin-session-cookie-secure.test.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const cookieSet = vi.fn(); +vi.mock('next/headers', () => ({ cookies: async () => ({ set: cookieSet, get: () => undefined, delete: vi.fn() }) })); + +describe('admin session cookie Secure flag', () => { + beforeEach(() => { cookieSet.mockClear(); vi.stubEnv('SESSION_SECRET', 'a'.repeat(64)); }); + afterEach(() => vi.unstubAllEnvs()); + + const secureFlagFor = async (request?: Request) => { + const { setAdminSessionCookie } = await import('@/lib/admin/session'); + await setAdminSessionCookie(request); + expect(cookieSet).toHaveBeenCalledTimes(1); + return (cookieSet.mock.calls[0]![2] as { secure: boolean }).secure; + }; + + it('follows the request like the setup wizard: Secure behind an HTTPS proxy', async () => { + expect(await secureFlagFor(new Request('http://app:3000/api/admin/auth', { headers: { 'x-forwarded-proto': 'https' } }))).toBe(true); + }); + + it('is not Secure on plain HTTP, so the browser keeps the cookie instead of dropping it silently', async () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(await secureFlagFor(new Request('http://app:3000/api/admin/auth'))).toBe(false); + }); + + it('lets COOKIE_SECURE win over the request', async () => { + vi.stubEnv('COOKIE_SECURE', 'true'); + expect(await secureFlagFor(new Request('http://app:3000/api/admin/auth', { headers: { 'x-forwarded-proto': 'http' } }))).toBe(true); + cookieSet.mockClear(); + vi.stubEnv('COOKIE_SECURE', 'false'); + expect(await secureFlagFor(new Request('https://mail.example/api/admin/auth', { headers: { 'x-forwarded-proto': 'https' } }))).toBe(false); + }); + + it('keeps the production default when no request is passed', async () => { + vi.stubEnv('NODE_ENV', 'production'); + expect(await secureFlagFor()).toBe(true); + }); +}); diff --git a/lib/__tests__/calendar-participants.test.ts b/lib/__tests__/calendar-participants.test.ts index 9169d9660..19241227d 100644 --- a/lib/__tests__/calendar-participants.test.ts +++ b/lib/__tests__/calendar-participants.test.ts @@ -605,6 +605,23 @@ describe('buildParticipantMap', () => { }); }); + it('omits blank names rather than sending an empty one', () => { + const map = buildParticipantMap( + { name: '', email: 'alice@example.com' }, + [ + { name: ' ', email: 'bob@example.com' }, + { name: ' Carol ', email: 'carol@example.com' }, + ] + ); + const entries = Object.values(map); + + // An empty name becomes a bare `CN=` downstream, which renders as a dangling + // "- Organizer" and as guests listed with nothing before their address. + expect(entries.find(p => p.email === 'alice@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'bob@example.com')).not.toHaveProperty('name'); + expect(entries.find(p => p.email === 'carol@example.com')!.name).toBe('Carol'); + }); + it('sets kind to individual for all entries', () => { const map = buildParticipantMap( { name: 'Alice', email: 'alice@example.com' }, diff --git a/lib/__tests__/impersonation-master-config.test.ts b/lib/__tests__/impersonation-master-config.test.ts new file mode 100644 index 000000000..cefdac631 --- /dev/null +++ b/lib/__tests__/impersonation-master-config.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { readImpersonationConfig } from '@/lib/impersonation/master-config'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-impersonation-')); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +async function secretFile(name: string, value: string): Promise { + const file = path.join(dir, name); + await writeFile(file, `${value}\n`); + return file; +} + +describe('readImpersonationConfig', () => { + it('reads the secrets from *_FILE when the plain variables are unset', async () => { + vi.stubEnv('BULWARK_JWT_AUTH_SECRET_FILE', await secretFile('jwt', 'jwt-secret')); + vi.stubEnv('BULWARK_STALWART_MASTER_USER_FILE', await secretFile('user', 'master')); + vi.stubEnv('BULWARK_STALWART_MASTER_PASSWORD_FILE', await secretFile('pass', 'hunter2')); + + expect(readImpersonationConfig()).toMatchObject({ + jwtSecret: 'jwt-secret', + masterUser: 'master', + masterPassword: 'hunter2', + }); + }); + + it('falls back to *_FILE when a plain variable is set but empty', async () => { + // What uncommenting `BULWARK_JWT_AUTH_SECRET=` in .env.example leaves behind. + vi.stubEnv('BULWARK_JWT_AUTH_SECRET', ''); + vi.stubEnv('BULWARK_JWT_AUTH_SECRET_FILE', await secretFile('jwt', 'jwt-secret')); + vi.stubEnv('BULWARK_STALWART_MASTER_USER', 'master'); + vi.stubEnv('BULWARK_STALWART_MASTER_PASSWORD', 'hunter2'); + + expect(readImpersonationConfig()?.jwtSecret).toBe('jwt-secret'); + }); + + it('prefers the plain variable over the file', async () => { + vi.stubEnv('BULWARK_JWT_AUTH_SECRET', 'from-env'); + vi.stubEnv('BULWARK_JWT_AUTH_SECRET_FILE', await secretFile('jwt', 'from-file')); + vi.stubEnv('BULWARK_STALWART_MASTER_USER', 'master'); + vi.stubEnv('BULWARK_STALWART_MASTER_PASSWORD', 'hunter2'); + + expect(readImpersonationConfig()?.jwtSecret).toBe('from-env'); + }); +}); diff --git a/lib/__tests__/request-protocol.test.ts b/lib/__tests__/request-protocol.test.ts new file mode 100644 index 000000000..5d920662a --- /dev/null +++ b/lib/__tests__/request-protocol.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { isHttpsRequest } from '@/lib/security/request-protocol'; + +const req = (url: string, headers: Record = {}) => new Request(url, { headers }); + +describe('isHttpsRequest', () => { + it('trusts the proxy header over the scheme the app itself sees', () => { + // The app always sees plain HTTP behind a TLS-terminating proxy. + expect(isHttpsRequest(req('http://webmail.internal:3000/api/admin/auth', { 'x-forwarded-proto': 'https' }))).toBe(true); + expect(isHttpsRequest(req('https://webmail.internal/api/admin/auth', { 'x-forwarded-proto': 'http' }))).toBe(false); + }); + + it('reads the first hop of a comma-separated forwarded list, case-insensitively', () => { + expect(isHttpsRequest(req('http://x/', { 'x-forwarded-proto': 'HTTPS, http' }))).toBe(true); + expect(isHttpsRequest(req('http://x/', { 'x-forwarded-proto': ' http ,https' }))).toBe(false); + }); + + it('falls back to the request URL without a proxy', () => { + expect(isHttpsRequest(req('https://localhost:3000/'))).toBe(true); + expect(isHttpsRequest(req('http://localhost:3000/'))).toBe(false); + }); +}); diff --git a/lib/admin/__tests__/plugin-signing.test.ts b/lib/admin/__tests__/plugin-signing.test.ts new file mode 100644 index 000000000..92f625d53 --- /dev/null +++ b/lib/admin/__tests__/plugin-signing.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'; +import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { + getPublicKeyBase64, + invalidatePluginSigningCache, + signBytes, +} from '../plugin-signing'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-plugin-signing-')); + invalidatePluginSigningCache(); +}); + +afterEach(async () => { + invalidatePluginSigningCache(); + vi.unstubAllEnvs(); + await rm(dir, { recursive: true, force: true }); +}); + +function privateKeyPem(type: 'ed25519' | 'rsa' = 'ed25519'): string { + const privateKey = type === 'rsa' + ? generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey + : generateKeyPairSync('ed25519').privateKey; + return privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; +} + +describe('plugin signing key storage', () => { + it('loads an external Ed25519 PKCS#8 key without touching it or the config directory', async () => { + const keyPath = path.join(dir, 'secrets', 'plugin-signing.pem'); + const configDir = path.join(dir, 'immutable-config'); + await mkdir(path.dirname(keyPath)); + const pem = privateKeyPem(); + await writeFile(keyPath, pem, { mode: 0o400 }); + const before = await stat(keyPath); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + vi.stubEnv('ADMIN_CONFIG_READONLY', 'true'); + + const payload = Buffer.from('external signing key'); + const signature = Buffer.from(await signBytes(payload), 'base64'); + const publicKey = createPublicKey(pem); + + expect(verify(null, payload, publicKey, signature)).toBe(true); + expect(await readFile(keyPath, 'utf8')).toBe(pem); + const after = await stat(keyPath); + expect(after.mode).toBe(before.mode); + expect(after.mtimeMs).toBe(before.mtimeMs); + await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('keeps loading an existing key from ADMIN_CONFIG_DIR when external path is unset', async () => { + const configDir = path.join(dir, 'config'); + const pem = privateKeyPem(); + await mkdir(configDir); + await writeFile(path.join(configDir, 'plugin-signing.key'), pem); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + const expected = createPublicKey(pem).export({ type: 'spki', format: 'der' }) as Buffer; + await expect(getPublicKeyBase64()).resolves.toBe(expected.subarray(-32).toString('base64')); + }); + + it('treats an empty PLUGIN_SIGNING_KEY_FILE as unset', async () => { + const configDir = path.join(dir, 'config'); + const pem = privateKeyPem(); + await mkdir(configDir); + await writeFile(path.join(configDir, 'plugin-signing.key'), pem); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', ''); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + const expected = createPublicKey(pem).export({ type: 'spki', format: 'der' }) as Buffer; + await expect(getPublicKeyBase64()).resolves.toBe(expected.subarray(-32).toString('base64')); + }); + + it('keeps generating plugin-signing.key when external path is unset', async () => { + const configDir = path.join(dir, 'config'); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + await expect(signBytes('generated key')).resolves.toMatch(/^[A-Za-z0-9+/]{86}==$/); + const generated = await readFile(path.join(configDir, 'plugin-signing.key'), 'utf8'); + expect(generated).toContain('-----BEGIN PRIVATE KEY-----'); + }); + + it('reports a missing external key without falling back to generation', async () => { + const keyPath = path.join(dir, 'missing.pem'); + const configDir = path.join(dir, 'config'); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + vi.stubEnv('ADMIN_CONFIG_DIR', configDir); + + await expect(signBytes('payload')).rejects.toThrow( + 'Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE', + ); + await expect(access(configDir)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports malformed external key data without exposing its contents', async () => { + const keyPath = path.join(dir, 'malformed.pem'); + const secret = 'not-a-private-key-secret'; + await writeFile(keyPath, secret); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + + const error = await signBytes('payload').then( + () => null, + (caught: unknown) => caught as Error, + ); + expect(error).not.toBeNull(); + expect(error!.message).toContain('valid PEM PKCS#8 private key'); + expect(error!.message).not.toContain(secret); + }); + + it('reports an external private key of the wrong asymmetric type', async () => { + const keyPath = path.join(dir, 'rsa.pem'); + await writeFile(keyPath, privateKeyPem('rsa')); + vi.stubEnv('PLUGIN_SIGNING_KEY_FILE', keyPath); + + await expect(signBytes('payload')).rejects.toThrow( + 'PLUGIN_SIGNING_KEY_FILE has wrong key type (rsa); expected ed25519', + ); + }); +}); diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index fcafadb06..fee57fe8d 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -58,12 +58,17 @@ class ConfigManager { */ get(key: string, defaultValue?: T): T { // Admin override (highest priority) + const mapping = CONFIG_ENV_MAP[key]; if (key in this.adminConfig) { return this.adminConfig[key] as T; + } else if (mapping && mapping.fileKey && mapping.fileKey in this.adminConfig) { + const fileVal = readFileEnv(this.adminConfig[mapping.fileKey]); + if (fileVal !== null) { + return parseEnvValue(fileVal, mapping.type) as T; + } } // Environment variable - const mapping = CONFIG_ENV_MAP[key]; if (mapping) { const envVal = process.env[mapping.envVar]; if (envVal !== undefined) { @@ -101,6 +106,14 @@ class ConfigManager { for (const [key, mapping] of Object.entries(CONFIG_ENV_MAP)) { if (key in this.adminConfig) { result[key] = { value: this.adminConfig[key], source: 'admin' }; + } else if (mapping.fileKey && mapping.fileKey in this.adminConfig) { + const fileVal = readFileEnv(this.adminConfig[mapping.fileKey]); + if (fileVal !== null) { + result[key] = { value: parseEnvValue(fileVal, mapping.type), source: 'admin' }; + continue; + } + + result[key] = { value: mapping.defaultValue, source: 'default' }; } else { const envVal = process.env[mapping.envVar]; if (envVal !== undefined) { diff --git a/lib/admin/plugin-signing.ts b/lib/admin/plugin-signing.ts index cf0ff2473..f4591a9f8 100644 --- a/lib/admin/plugin-signing.ts +++ b/lib/admin/plugin-signing.ts @@ -5,10 +5,10 @@ // attacker swaps the bundle bytes in transit or at rest, the client refuses // to load anything that doesn't verify against the host's public key. // -// The keypair lives at `data/admin/plugin-signing.key` (PEM-encoded -// PKCS#8 private, mode 0600) and is generated lazily on first use. Operators -// who want to pin the key out-of-band can drop a pre-generated PEM at that -// path before first boot - the loader just reads what's there. +// By default, the keypair lives at `data/admin/plugin-signing.key` +// (PEM-encoded PKCS#8 private, mode 0600) and is generated lazily on first +// use. Operators can instead point PLUGIN_SIGNING_KEY_FILE at a read-only +// secret outside the admin config directory. import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto'; import { readFile, writeFile, chmod } from 'node:fs/promises'; @@ -21,18 +21,44 @@ const KEY_FILENAME = 'plugin-signing.key'; let cached: { privateKey: KeyObject; publicKey: KeyObject } | null = null; let initPromise: Promise | null = null; +function parseKey(pem: string, source: string): { privateKey: KeyObject; publicKey: KeyObject } { + let privateKey: KeyObject; + try { + if (!pem.includes('-----BEGIN PRIVATE KEY-----')) throw new Error('not PKCS#8 PEM'); + privateKey = createPrivateKey({ key: pem, format: 'pem', type: 'pkcs8' }); + } catch { + throw new Error(`${source} is not a valid PEM PKCS#8 private key`); + } + if (privateKey.asymmetricKeyType !== 'ed25519') { + throw new Error(`${source} has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`); + } + return { privateKey, publicKey: createPublicKey(privateKey) }; +} + +async function loadExternal(path: string): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> { + let pem: string; + try { + pem = await readFile(path, 'utf-8'); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Cannot read plugin signing key from PLUGIN_SIGNING_KEY_FILE: ${detail}`); + } + return parseKey(pem, 'PLUGIN_SIGNING_KEY_FILE'); +} + async function loadOrCreate(): Promise<{ privateKey: KeyObject; publicKey: KeyObject }> { + // An empty value (`PLUGIN_SIGNING_KEY_FILE=` in an env file) means unset, + // like the other *_FILE variables. Reading '' fails, and a failed load + // makes every bundle ship unsigned with only a console warning. + const externalPath = process.env.PLUGIN_SIGNING_KEY_FILE?.trim(); + if (externalPath) return loadExternal(externalPath); + await ensureConfigDir(); const path = getConfigPath(KEY_FILENAME); if (existsSync(path)) { const pem = await readFile(path, 'utf-8'); - const privateKey = createPrivateKey({ key: pem, format: 'pem' }); - if (privateKey.asymmetricKeyType !== 'ed25519') { - throw new Error(`plugin-signing.key has wrong key type (${privateKey.asymmetricKeyType}); expected ed25519`); - } - const publicKey = createPublicKey(privateKey); - return { privateKey, publicKey }; + return parseKey(pem, KEY_FILENAME); } // First boot: generate and persist. Use sync APIs so a half-written file diff --git a/lib/admin/session.ts b/lib/admin/session.ts index c097ec0fc..1e65b5fcd 100644 --- a/lib/admin/session.ts +++ b/lib/admin/session.ts @@ -1,4 +1,5 @@ import { cookies } from 'next/headers'; +import { isHttpsRequest } from '@/lib/security/request-protocol'; import { NextResponse } from 'next/server'; import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto'; import { getSessionSecret } from '@/lib/auth/session-secret'; @@ -115,13 +116,23 @@ export async function requireAdminAuth(request: Request): Promise<{ payload: Adm /** * Set the admin session cookie. + * + * `Secure` follows the request the way the setup wizard's cookie does: the + * browser drops Secure cookies on plain HTTP, so setting it unconditionally in + * production made admin sign-in fail silently on an HTTP deployment while the + * wizard right next to it worked. Without a request (older callers) the + * previous production default stands. An explicit COOKIE_SECURE wins over + * both, as it does for the mailbox cookies: a TLS proxy that does not send + * X-Forwarded-Proto would otherwise make the request look like plain HTTP. */ -export async function setAdminSessionCookie(): Promise { +export async function setAdminSessionCookie(request?: Request): Promise { const token = createAdminSession(); const cookieStore = await cookies(); cookieStore.set(ADMIN_SESSION_COOKIE, token, { httpOnly: true, - secure: process.env.NODE_ENV === 'production', + secure: process.env.COOKIE_SECURE !== undefined + ? process.env.COOKIE_SECURE === 'true' + : request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: getSessionTTL(), diff --git a/lib/admin/types.ts b/lib/admin/types.ts index bb4c95200..82ada29e6 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -188,7 +188,7 @@ export interface AuditEntry { } /** Config keys that map to environment variables */ -export const CONFIG_ENV_MAP: Record = { +export const CONFIG_ENV_MAP: Record = { appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' }, appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' }, appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' }, @@ -235,7 +235,7 @@ export const CONFIG_ENV_MAP: Record generateUUID(); + // An empty name is not a name: sent as "", it reaches the iCalendar stream as a + // bare `CN=`, and recipients see a dangling "- Organizer" or a guest listed with + // nothing before their address. Omitted, clients fall back to the address. + const named = (name: string) => (name.trim() ? { name: name.trim() } : {}); + // calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis // (implemented by Stalwart); the RFC 8984 sendTo property is retired there and // stored as an inert JSPROP, so it is intentionally not sent. participants[generateId()] = { '@type': 'Participant', - name: organizer.name, + ...named(organizer.name), email: organizer.email, calendarAddress: `mailto:${organizer.email}`, // owner only, NOT attendee: with roles.attendee set, Stalwart's server-side @@ -279,7 +284,7 @@ export function buildParticipantMap( participants[generateId()] = { '@type': 'Participant', - name: a.name, + ...named(a.name), email, calendarAddress: `mailto:${email}`, roles: { attendee: true }, diff --git a/lib/impersonation/master-config.ts b/lib/impersonation/master-config.ts index 392d3d151..0d9c3916e 100644 --- a/lib/impersonation/master-config.ts +++ b/lib/impersonation/master-config.ts @@ -1,4 +1,5 @@ import { configManager } from '@/lib/admin/config-manager'; +import { readFileEnv } from "@/lib/read-file-env"; export interface ImpersonationConfig { jwtSecret: string; @@ -16,13 +17,27 @@ export interface ImpersonationConfig { * BULWARK_STALWART_MASTER_USER master account address (e.g. master@example.com) * BULWARK_STALWART_MASTER_PASSWORD * + * Alternatively the file-based env: + * BULWARK_JWT_AUTH_SECRET_FILE + * BULWARK_STALWART_MASTER_USER_FILE + * BULWARK_STALWART_MASTER_PASSWORD_FILE + * * Optional env: * BULWARK_JWT_AUTH_ISSUER (default: "platform-api/webmail") */ export function readImpersonationConfig(): ImpersonationConfig | null { - const jwtSecret = process.env.BULWARK_JWT_AUTH_SECRET ?? ''; - const masterUser = process.env.BULWARK_STALWART_MASTER_USER ?? ''; - const masterPassword = process.env.BULWARK_STALWART_MASTER_PASSWORD ?? ''; + const jwtSecret = + process.env.BULWARK_JWT_AUTH_SECRET || + readFileEnv(process.env.BULWARK_JWT_AUTH_SECRET_FILE) || + ""; + const masterUser = + process.env.BULWARK_STALWART_MASTER_USER || + readFileEnv(process.env.BULWARK_STALWART_MASTER_USER_FILE) || + ""; + const masterPassword = + process.env.BULWARK_STALWART_MASTER_PASSWORD || + readFileEnv(process.env.BULWARK_STALWART_MASTER_PASSWORD_FILE) || + ""; if (!jwtSecret || !masterUser || !masterPassword) return null; return { jwtSecret, diff --git a/lib/security/request-protocol.ts b/lib/security/request-protocol.ts new file mode 100644 index 000000000..7a8f3bf0b --- /dev/null +++ b/lib/security/request-protocol.ts @@ -0,0 +1,20 @@ +/** + * Whether the browser reached us over HTTPS. + * + * The app itself only ever speaks plain HTTP: TLS terminates at the reverse + * proxy in front of it, which is expected to say so in `X-Forwarded-Proto`. + * Without a proxy the request URL's own scheme is the truth. Both the setup + * wizard and the admin session derive the cookie `Secure` flag from this, so + * the answer is the same for both. + */ +export function isHttpsRequest(request: Request): boolean { + const forwarded = request.headers.get('x-forwarded-proto'); + if (forwarded) { + return forwarded.split(',')[0]!.trim().toLowerCase() === 'https'; + } + try { + return new URL(request.url).protocol === 'https:'; + } catch { + return false; + } +} diff --git a/lib/setup/session.ts b/lib/setup/session.ts index 46938d585..c63df439c 100644 --- a/lib/setup/session.ts +++ b/lib/setup/session.ts @@ -1,6 +1,7 @@ import { cookies } from 'next/headers'; import type { NextRequest } from 'next/server'; import { verifySetupToken } from './token'; +import { isHttpsRequest } from '@/lib/security/request-protocol'; export const SETUP_COOKIE = 'bulwark_setup_token'; const COOKIE_MAX_AGE = 60 * 60; // 1 hour, matches token TTL @@ -37,11 +38,3 @@ export function buildSessionCookieAttributes(request?: NextRequest) { maxAge: COOKIE_MAX_AGE, }; } - -function isHttpsRequest(request: NextRequest): boolean { - const forwarded = request.headers.get('x-forwarded-proto'); - if (forwarded) { - return forwarded.split(',')[0]!.trim().toLowerCase() === 'https'; - } - return request.nextUrl.protocol === 'https:'; -}