mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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.
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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:';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user