feat: expose PWA, app identity, and extension directory keys in JSON config #312

This commit is contained in:
Linus Rath
2026-05-21 23:35:58 +02:00
parent 9b22ef810e
commit fc5f6f43d6
58 changed files with 159 additions and 130 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ describe('impersonationReplayCache', () => {
it('prunes expired jtis on next consume', () => {
const now = Math.floor(Date.now() / 1000);
impersonationReplayCache.consume('jti-old', now - 600, now - 600);
// Far in the future pruning should clear the old entry.
// Far in the future - pruning should clear the old entry.
expect(impersonationReplayCache.consume('jti-new', now + 60, now + 1000)).toBe(true);
// Re-using the old jti is allowed after pruning (security irrelevant since
// the token would fail signature/exp validation upstream).
+1 -1
View File
@@ -7,7 +7,7 @@
// run.
//
// Each entry has one of three states: 'pending' (user installed, waiting for
// admin), 'approved' (admin signed off), 'denied' (admin refused kept so we
// admin), 'approved' (admin signed off), 'denied' (admin refused - kept so we
// don't keep asking).
import { readFile, writeFile, rename } from 'node:fs/promises';
+1 -1
View File
@@ -155,7 +155,7 @@ async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null>
// Hash from the exact bytes the bundle endpoint will serve so the client's
// verifyBundle check passes. For src/ sources that means running esbuild
// here too slightly more work per manifest list, but unavoidable since
// here too - slightly more work per manifest list, but unavoidable since
// the source hash wouldn't match the served bundle.
let bundleHash: string;
try {
+1 -1
View File
@@ -8,7 +8,7 @@
// 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.
// path before first boot - the loader just reads what's there.
import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign, KeyObject } from 'node:crypto';
import { readFile, writeFile, chmod } from 'node:fs/promises';
+6
View File
@@ -128,11 +128,16 @@ 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[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
appShortName: { envVar: 'APP_SHORT_NAME', type: 'string', defaultValue: '' },
appDescription: { envVar: 'APP_DESCRIPTION', type: 'string', defaultValue: '' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
demoMode: { envVar: 'DEMO_MODE', type: 'boolean', defaultValue: false },
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
appLogoDarkUrl: { envVar: 'APP_LOGO_DARK_URL', type: 'url', defaultValue: '' },
loginLogoLightUrl: { envVar: 'LOGIN_LOGO_LIGHT_URL', type: 'url', defaultValue: '/branding/Bulwark_Logo_Color.svg' },
@@ -159,6 +164,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
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: '' },
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
};
/** Keys that should never be exposed to the client config endpoint */
+6 -6
View File
@@ -82,7 +82,7 @@ export function verifyImpersonationJwt(
}
const [headerB64, payloadB64, sigB64] = parts;
// Header reject anything but HS256 BEFORE attempting signature verification.
// Header - reject anything but HS256 BEFORE attempting signature verification.
const header = parseSegment(headerB64) as Record<string, unknown>;
if (header.alg !== 'HS256') {
throw new ImpersonationJwtError('alg', `Unsupported alg '${String(header.alg)}'`);
@@ -91,7 +91,7 @@ export function verifyImpersonationJwt(
throw new ImpersonationJwtError('alg', `Unsupported typ '${String(header.typ)}'`);
}
// Signature constant-time compare.
// Signature - constant-time compare.
const expected = createHmac('sha256', secret).update(`${headerB64}.${payloadB64}`).digest();
const provided = base64UrlDecode(sigB64);
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
@@ -109,7 +109,7 @@ export function verifyImpersonationJwt(
const jti = assertString(payload.jti, 'jti');
const mailbox = assertString(payload.mailbox, 'mailbox');
// Mailbox MUST NOT contain '%' or ':' those would inject into the
// Mailbox MUST NOT contain '%' or ':' - those would inject into the
// master-user auth header.
if (mailbox.includes('%') || mailbox.includes(':')) {
throw new ImpersonationJwtError('mailbox', "mailbox must not contain '%' or ':'");
@@ -126,7 +126,7 @@ export function verifyImpersonationJwt(
if (iat - CLOCK_SKEW_SEC > nowSec) {
throw new ImpersonationJwtError('iat', 'Token issued in the future');
}
// Hard ceiling on lifetime refuse long-lived handoff tokens even if the
// Hard ceiling on lifetime - refuse long-lived handoff tokens even if the
// signer asked for one.
if (exp - iat > MAX_TOKEN_LIFETIME_SEC) {
throw new ImpersonationJwtError('lifetime', `Token lifetime exceeds ${MAX_TOKEN_LIFETIME_SEC}s ceiling`);
@@ -158,7 +158,7 @@ class ReplayCache {
this.prune(now);
if (this.entries.has(jti)) return false;
if (this.entries.size >= REPLAY_CACHE_MAX) {
// Evict the oldest entry Map preserves insertion order.
// Evict the oldest entry - Map preserves insertion order.
const first = this.entries.keys().next().value;
if (first !== undefined) this.entries.delete(first);
}
@@ -171,7 +171,7 @@ class ReplayCache {
if (exp + CLOCK_SKEW_SEC < now) {
this.entries.delete(jti);
} else {
// Insertion order means later entries are no older than this one but
// Insertion order means later entries are no older than this one - but
// exp isn't strictly monotonic with insertion, so we can't break here.
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ export interface ImpersonationConfig {
}
/**
* Returns null when impersonation is not configured the route MUST surface
* Returns null when impersonation is not configured - the route MUST surface
* that as a 404 so an unconfigured deployment doesn't expose the endpoint.
*
* Required env:
@@ -38,7 +38,7 @@ export function readImpersonationConfig(): ImpersonationConfig | null {
* legacy env fallbacks. Returns null if none is configured.
*
* The impersonation flow is server-to-server (no user input), so we never
* accept a custom endpoint only admin-configured URLs.
* accept a custom endpoint - only admin-configured URLs.
*/
export async function resolveImpersonationServerUrl(): Promise<string | null> {
await configManager.ensureLoaded();
+4 -4
View File
@@ -159,7 +159,7 @@ export interface Identity {
textSignature?: string;
htmlSignature?: string;
mayDelete: boolean;
// See `Calendar.localAccountId` set when the Pro shell aggregates
// See `Calendar.localAccountId` - set when the Pro shell aggregates
// identities from multiple connected accounts so we can route sends
// back through the owning JMAP client. `accountName` is the
// user-facing label for the dropdown's optgroup.
@@ -178,7 +178,7 @@ export interface ContactCard {
accountId?: string;
accountName?: string;
isShared?: boolean;
// Local account-store ID set when the Pro shell aggregates contacts
// Local account-store ID - set when the Pro shell aggregates contacts
// from multiple connected accounts. See `Calendar.localAccountId`.
localAccountId?: string;
language?: string;
@@ -381,7 +381,7 @@ export interface AddressBook {
accountId?: string;
accountName?: string;
isShared?: boolean;
// See `Calendar.localAccountId` same purpose for address books.
// See `Calendar.localAccountId` - same purpose for address books.
localAccountId?: string;
}
@@ -483,7 +483,7 @@ export interface CalendarEvent {
accountId?: string;
accountName?: string;
isShared?: boolean;
// See `Calendar.localAccountId` same purpose for events.
// See `Calendar.localAccountId` - same purpose for events.
localAccountId?: string;
isDraft: boolean;
isOrigin: boolean;
+1 -1
View File
@@ -19,7 +19,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
* so blob-imported plugin code could resolve `react`. With the sandbox model
* plugins receive React injected as a function argument inside their iframe
* runtime there is nothing to expose on the host window.
* runtime - there is nothing to expose on the host window.
*
* Kept as a no-op for callers that still invoke it during app bootstrap.
*/
+1 -1
View File
@@ -6,7 +6,7 @@
// a bundle the loader verifies the signature; mismatch refuses the load.
//
// User-installed plugins (uploaded via the file picker, no server hop) have
// no signature verification is skipped for those, since the user is
// no signature - verification is skipped for those, since the user is
// installing their own code. Verification kicks in for server-managed
// bundles only (the `managed: true` flag on `InstalledPlugin`).
+2 -2
View File
@@ -28,7 +28,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'admin.getAllConfig': 'admin:config',
'admin.setConfig': 'admin:config',
'admin.deleteConfig': 'admin:config',
// ui any plugin can ask the host to render a modal or open a URL.
// ui - any plugin can ask the host to render a modal or open a URL.
'ui.confirm': null,
'ui.alert': null,
'ui.openExternalUrl': null,
@@ -289,7 +289,7 @@ export async function dispatchApiCall(
}
case 'ui.openExternalUrl': {
const url = String(args[0] ?? '');
// Only http(s) the sandbox should not be able to navigate the host
// Only http(s) - the sandbox should not be able to navigate the host
// anywhere internal, nor open javascript:/data:/file: schemes.
let parsed: URL;
try { parsed = new URL(url); } catch { throw new Error('ui.openExternalUrl: invalid URL'); }
+3 -3
View File
@@ -39,7 +39,7 @@ function encodeCallbacks(
if (Array.isArray(value)) {
return value.map((v) => encodeCallbacks(v, table, depth + 1));
}
// Plain object copy own enumerable keys.
// Plain object - copy own enumerable keys.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = encodeCallbacks(v, table, depth + 1);
@@ -161,7 +161,7 @@ export class SandboxInstance {
private send(msg: HostToSandbox): void {
// targetOrigin '*' is required because the iframe is opaque-origin. The
// payload contains no host secrets bundle code and manifest fields the
// payload contains no host secrets - bundle code and manifest fields the
// plugin already owns.
this.iframe.contentWindow?.postMessage(msg, '*');
}
@@ -236,7 +236,7 @@ export class SandboxInstance {
}
case 'slot-resize':
// The iframe has no intrinsic height sync it to the content height
// The iframe has no intrinsic height - sync it to the content height
// the sandbox reported, otherwise the wrapper reserves space but the
// iframe stays at 0px and the slot appears blank.
this.iframe.style.height = `${msg.height}px`;
+2 -2
View File
@@ -181,7 +181,7 @@ function buildPluginApi(manifest: PluginManifest) {
/**
* Resolve a bundler-emitted `require(name)` call inside the sandbox. Plugin
* bundlers should be configured to externalise React; the runtime provides
* those modules here. Anything else is refused the sandbox has no Node-
* those modules here. Anything else is refused - the sandbox has no Node-
* compatible module resolution and we don't want plugins probing globals.
*
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
@@ -337,7 +337,7 @@ function bootSlot(payload: SlotInit): void {
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}
// Populated by bootSlot receives `props-update` messages.
// Populated by bootSlot - receives `props-update` messages.
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
async function handleInit(payload: InitPayload): Promise<void> {
+1 -1
View File
@@ -25,7 +25,7 @@ export async function authenticateWizardRequest(): Promise<boolean> {
export function buildSessionCookieAttributes(request?: NextRequest) {
// Match Secure to the actual request protocol. Browsers drop Secure cookies
// on plain HTTP, so unconditionally setting Secure in production breaks
// setup over HTTP the operator gets "Wizard session required" on every
// setup over HTTP - the operator gets "Wizard session required" on every
// step. The wizard surfaces a cleartext-credentials warning in the UI when
// HTTPS isn't in use.
return {
+8 -8
View File
@@ -57,7 +57,7 @@ function unfoldLines(vcf: string): string {
.replace(/\n[ \t]/g, "");
}
// RFC 6868 parameter value encoding used inside parameter values only.
// RFC 6868 parameter value encoding - used inside parameter values only.
// Caret-encoded sequences: ^n → LF, ^^ → ^, ^' → DQUOTE.
function decodeParamValue(s: string): string {
let out = "";
@@ -301,7 +301,7 @@ export function parseVCard(vcfString: string): ContactCard[] {
function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${generateUUID()}`;
const card: ContactCard = { id, addressBookIds: {} };
// Deferred BIRTHPLACE/DEATHPLACE values attach to anniversary at end,
// Deferred BIRTHPLACE/DEATHPLACE values - attach to anniversary at end,
// because the BDAY/DEATHDATE entry may appear in any order.
let birthPlace: string | undefined;
let deathPlace: string | undefined;
@@ -465,7 +465,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
mediaType: mime,
};
} else if (val.startsWith("data:") || val.startsWith("http://") || val.startsWith("https://")) {
// vCard 4.0 URI value (data URI or URL) no ENCODING param.
// vCard 4.0 URI value (data URI or URL) - no ENCODING param.
card.media[`m${idx}`] = {
kind: "photo",
uri: val,
@@ -760,7 +760,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
}
case "ORG-DIRECTORY": {
// RFC 6715 §2.4 directory URI for the contact's organization.
// RFC 6715 §2.4 - directory URI for the contact's organization.
if (!card.directories) card.directories = {};
const idx = Object.keys(card.directories).length;
card.directories[`d${idx}`] = {
@@ -789,14 +789,14 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break;
case "GRAMGENDER": {
// RFC 9554 §3.4 grammatical gender (animate/common/feminine/masculine/neuter).
// RFC 9554 §3.4 - grammatical gender (animate/common/feminine/masculine/neuter).
if (!card.speakToAs) card.speakToAs = {};
card.speakToAs.grammaticalGender = val.toLowerCase();
break;
}
case "PRONOUNS": {
// RFC 9554 §3.5 free-form pronouns. May appear multiple times.
// RFC 9554 §3.5 - free-form pronouns. May appear multiple times.
if (!card.speakToAs) card.speakToAs = {};
if (!card.speakToAs.pronouns) card.speakToAs.pronouns = {};
const pkey = `p${Object.keys(card.speakToAs.pronouns).length}`;
@@ -1058,7 +1058,7 @@ function generateSingleVCard(contact: ContactCard): string {
}
if (contact.personalInfo) {
// RFC 6715 emit EXPERTISE / HOBBY / INTEREST with LEVEL.
// RFC 6715 - emit EXPERTISE / HOBBY / INTEREST with LEVEL.
const levelOut: Record<string, Record<string, string>> = {
expertise: { high: "expert", medium: "average", low: "beginner" },
hobby: { high: "high", medium: "medium", low: "low" },
@@ -1167,7 +1167,7 @@ function generateSingleVCard(contact: ContactCard): string {
}
if (contact.created) {
// RFC 9554 §3.1 CREATED is a timestamp; emit as-is for round-trip.
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
lines.push(`CREATED:${contact.created}`);
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Lenient semver comparison for the marketplace's `minAppVersion` gate.
*
* Parses "major.minor.patch" (any segment may be missing treated as 0)
* Parses "major.minor.patch" (any segment may be missing - treated as 0)
* and ignores pre-release / build metadata. Returns negative, zero or
* positive in the same shape as Array.prototype.sort comparators.
*