This commit is contained in:
Linus Rath
2026-09-30 14:50:52 +02:00
17 changed files with 400 additions and 35 deletions
+6
View File
@@ -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
Binary file not shown.
+5 -2
View File
@@ -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',
+21 -4
View File
@@ -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<CalendarEvent> = {
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")}
</Button>
</div>
)}
@@ -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);
});
});
@@ -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' },
@@ -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<string> {
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');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isHttpsRequest } from '@/lib/security/request-protocol';
const req = (url: string, headers: Record<string, string> = {}) => 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);
});
});
+124
View File
@@ -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',
);
});
});
+14 -1
View File
@@ -58,12 +58,17 @@ class ConfigManager {
*/
get<T>(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(<string | undefined>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(<string | undefined>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) {
+36 -10
View File
@@ -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<void> | 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
+13 -2
View File
@@ -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<void> {
export async function setAdminSessionCookie(request?: Request): Promise<void> {
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(),
+3 -3
View File
@@ -188,7 +188,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; fileKey?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
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<string, { envVar: string; fileEnvVar?: strin
oauthEnabled: { envVar: 'OAUTH_ENABLED', type: 'boolean', defaultValue: false },
oauthOnly: { envVar: 'OAUTH_ONLY', type: 'boolean', defaultValue: false },
oauthClientId: { envVar: 'OAUTH_CLIENT_ID', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', fileKey: "oauthClientSecretFile", type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
// Overrides only the user-facing authorize endpoint. Discovery, token exchange
// and refresh continue to use the canonical OAUTH_ISSUER_URL. Lets a per-brand
@@ -274,7 +274,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
settingsSyncEnabled: { envVar: 'SETTINGS_SYNC_ENABLED', type: 'boolean', defaultValue: false },
logFormat: { envVar: 'LOG_FORMAT', type: 'enum', defaultValue: 'text', enumValues: ['text', 'json'] },
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', fileKey: "sessionSecretFile", type: 'string', defaultValue: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
// Connector links (connector.bulwarkmail.org): docs and the extension
// directory link to a destination, and the reader's own browser resolves it
+7 -2
View File
@@ -249,12 +249,17 @@ export function buildParticipantMap(
const generateId = () => 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 },
+18 -3
View File
@@ -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,
+20
View File
@@ -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;
}
}
+1 -8
View File
@@ -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:';
}