diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13c179f3..f64e5c42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 1.5.2 (2026-04-25)
+
+### Features
+
+- **Plugins**: New `composer-sidebar` slot and `ui:composer-sidebar` permission — plugins can now render a panel on the left side of the New Message dialog. See `repos/subway-surfers` for an example
+- **Plugins**: Manifests can declare `frameOrigins` — a strictly-validated list of `https://host` origins the plugin needs to embed. The proxy reads the union from enabled plugins and merges it into the host CSP `frame-src`, so the host CSP no longer needs to know about specific embed providers
+
## 1.5.1 (2026-04-25)
### Features
diff --git a/VERSION b/VERSION
index 26ca5946..4cda8f19 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.5.1
+1.5.2
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 341e9627..fdf25e61 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -10,6 +10,10 @@ import {
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
+import {
+ sanitizeFrameOrigins,
+ invalidateFrameOriginsCache,
+} from '@/lib/admin/csp-frame-origins';
import JSZip from 'jszip';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
}
+ // Plugins may declare iframe origins they need for embedded content.
+ // Anything that doesn't pass strict origin validation is silently
+ // dropped — the plugin still installs, but those origins are not
+ // added to the host CSP.
+ const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
+ const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
+ ? (manifest.frameOrigins as unknown[]).filter(
+ (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
+ )
+ : [];
+ if (droppedFrameOrigins.length > 0) {
+ warnings.push(
+ `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
+ );
+ }
+
const plugin: ServerPlugin = {
id: (manifest.id as string) || slug,
name: (manifest.name as string) || slug,
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
enabled: true,
installedAt: now,
updatedAt: now,
+ ...(declaredFrameOrigins.length > 0
+ ? { frameOrigins: declaredFrameOrigins }
+ : {}),
};
await savePlugin(plugin, code);
- await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug }, ip);
+ invalidateFrameOriginsCache();
+ await auditLog('marketplace.install_plugin', { id: plugin.id, name: plugin.name, version: plugin.version, slug, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ success: true, plugin, warnings });
}
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index 9f2f46ed..491dca0b 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -8,6 +8,10 @@ import {
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
+import {
+ sanitizeFrameOrigins,
+ invalidateFrameOriginsCache,
+} from '@/lib/admin/csp-frame-origins';
// Server-side extraction using the same validation logic
// ZIP parsing needs to happen on the server for admin-uploaded plugins
@@ -152,6 +156,8 @@ export async function POST(request: NextRequest) {
);
}
+ const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
+
const now = new Date().toISOString();
const plugin: ServerPlugin = {
id: manifest.id as string,
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
...(manifest.configSchema && typeof manifest.configSchema === 'object'
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
: {}),
+ ...(declaredFrameOrigins.length > 0
+ ? { frameOrigins: declaredFrameOrigins }
+ : {}),
installedAt: now,
updatedAt: now,
};
await savePlugin(plugin, code);
- await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version }, ip);
+ invalidateFrameOriginsCache();
+ await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins }, ip);
return NextResponse.json({ plugin });
} catch (error) {
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
+ // Enable/disable changes the set of plugins contributing frame origins.
+ if (typeof updates.enabled === 'boolean' || typeof updates.forceEnabled === 'boolean') {
+ invalidateFrameOriginsCache();
+ }
+
await auditLog('plugin.update', { id, ...updates }, ip);
return NextResponse.json({ plugin: updated });
} catch (error) {
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
+ invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
return NextResponse.json({ success: true });
} catch (error) {
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 2bf9e569..607a4edb 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -1100,8 +1100,13 @@ export function EmailComposer({
};
return (
+
);
}
diff --git a/lib/__tests__/csp-frame-origins.test.ts b/lib/__tests__/csp-frame-origins.test.ts
new file mode 100644
index 00000000..b89e8dfb
--- /dev/null
+++ b/lib/__tests__/csp-frame-origins.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isValidFrameOrigin,
+ sanitizeFrameOrigins,
+} from '@/lib/admin/csp-frame-origins';
+
+describe('isValidFrameOrigin', () => {
+ it('accepts plain https origins', () => {
+ expect(isValidFrameOrigin('https://www.youtube-nocookie.com')).toBe(true);
+ expect(isValidFrameOrigin('https://meet.example.com')).toBe(true);
+ expect(isValidFrameOrigin('https://a.b.c.example.com')).toBe(true);
+ });
+
+ it('accepts a wildcard subdomain', () => {
+ expect(isValidFrameOrigin('https://*.example.com')).toBe(true);
+ expect(isValidFrameOrigin('https://*.youtube.com')).toBe(true);
+ });
+
+ it('accepts an explicit port', () => {
+ expect(isValidFrameOrigin('https://meet.example.com:8443')).toBe(true);
+ expect(isValidFrameOrigin('https://*.example.com:443')).toBe(true);
+ });
+
+ it('rejects non-https schemes', () => {
+ expect(isValidFrameOrigin('http://example.com')).toBe(false);
+ expect(isValidFrameOrigin('ftp://example.com')).toBe(false);
+ expect(isValidFrameOrigin('data:text/html,foo')).toBe(false);
+ expect(isValidFrameOrigin('javascript:alert(1)')).toBe(false);
+ });
+
+ it('rejects bare schemes and wildcard hosts', () => {
+ expect(isValidFrameOrigin('https://')).toBe(false);
+ expect(isValidFrameOrigin('https://*')).toBe(false);
+ expect(isValidFrameOrigin('https://*.com')).toBe(false);
+ expect(isValidFrameOrigin('https://localhost')).toBe(false);
+ });
+
+ it('rejects paths, queries, and fragments', () => {
+ expect(isValidFrameOrigin('https://example.com/embed')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com/')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com?x=1')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com#x')).toBe(false);
+ });
+
+ it('rejects userinfo, IPs, and IPv6', () => {
+ expect(isValidFrameOrigin('https://user:pass@example.com')).toBe(false);
+ expect(isValidFrameOrigin('https://1.2.3.4')).toBe(false);
+ expect(isValidFrameOrigin('https://[::1]')).toBe(false);
+ });
+
+ it('rejects values that try to break out of the directive', () => {
+ expect(isValidFrameOrigin("https://example.com'; script-src 'unsafe-eval")).toBe(false);
+ expect(isValidFrameOrigin('https://example.com" data:')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com data:')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com\nhttps://evil.com')).toBe(false);
+ expect(isValidFrameOrigin('https://example.com;https://evil.com')).toBe(false);
+ expect(isValidFrameOrigin('https://exa,mple.com')).toBe(false);
+ });
+
+ it('rejects non-strings and obvious garbage', () => {
+ expect(isValidFrameOrigin(undefined)).toBe(false);
+ expect(isValidFrameOrigin(null)).toBe(false);
+ expect(isValidFrameOrigin(42)).toBe(false);
+ expect(isValidFrameOrigin('')).toBe(false);
+ expect(isValidFrameOrigin('not-a-url')).toBe(false);
+ expect(isValidFrameOrigin('a'.repeat(300))).toBe(false);
+ });
+});
+
+describe('sanitizeFrameOrigins', () => {
+ it('returns empty for non-array input', () => {
+ expect(sanitizeFrameOrigins(undefined)).toEqual([]);
+ expect(sanitizeFrameOrigins(null)).toEqual([]);
+ expect(sanitizeFrameOrigins('https://example.com')).toEqual([]);
+ expect(sanitizeFrameOrigins({})).toEqual([]);
+ });
+
+ it('keeps valid entries and drops invalid ones silently', () => {
+ expect(
+ sanitizeFrameOrigins([
+ 'https://www.youtube-nocookie.com',
+ 'http://insecure.com',
+ 'https://meet.example.com:8443',
+ 'https://example.com/path',
+ 42,
+ 'https://*.vimeo.com',
+ ]),
+ ).toEqual([
+ 'https://www.youtube-nocookie.com',
+ 'https://meet.example.com:8443',
+ 'https://*.vimeo.com',
+ ]);
+ });
+
+ it('dedupes case-insensitively', () => {
+ expect(
+ sanitizeFrameOrigins([
+ 'https://Example.com',
+ 'https://example.com',
+ 'https://EXAMPLE.com',
+ ]),
+ ).toEqual(['https://Example.com']);
+ });
+});
diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts
index 4ea88743..823cc335 100644
--- a/lib/__tests__/plugin-store.test.ts
+++ b/lib/__tests__/plugin-store.test.ts
@@ -47,6 +47,7 @@ function resetStore() {
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
+ 'composer-sidebar': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
diff --git a/lib/admin/csp-frame-origins.ts b/lib/admin/csp-frame-origins.ts
new file mode 100644
index 00000000..9fa211ea
--- /dev/null
+++ b/lib/admin/csp-frame-origins.ts
@@ -0,0 +1,102 @@
+/**
+ * Computes the union of CSP `frame-src` origins declared by installed and
+ * enabled plugins. The proxy reads this on each request so that plugins can
+ * embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
+ * domains in the host CSP.
+ *
+ * Origins are validated at install time and re-validated here as defense in
+ * depth — any malformed value is dropped so a corrupted registry can never
+ * inject arbitrary CSP fragments.
+ */
+
+import { getPluginRegistry } from './plugin-registry';
+
+// `https://host`, `https://host:port`, or `https://*.host[:port]`
+//
+// Each label is alphanumeric with optional inner dashes; the final TLD label
+// MUST start with a letter so we reject raw IPv4 literals.
+//
+// Disallowed by the regex (intentionally):
+// - any scheme other than https
+// - paths, queries, fragments
+// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
+// - bare wildcards (`https://*`)
+const FRAME_ORIGIN_RE =
+ /^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
+
+export function isValidFrameOrigin(origin: unknown): origin is string {
+ if (typeof origin !== 'string') return false;
+ if (origin.length > 200) return false;
+ if (!FRAME_ORIGIN_RE.test(origin)) return false;
+ // Reject control characters / whitespace as a final safeguard against
+ // anything that would let an attacker break out of the directive.
+ if (/[\s'"`;,()]/.test(origin)) return false;
+ return true;
+}
+
+/**
+ * Sanitises a list of candidate origins from a manifest. Drops invalid
+ * entries silently and dedupes (case-insensitive on the host).
+ */
+export function sanitizeFrameOrigins(input: unknown): string[] {
+ if (!Array.isArray(input)) return [];
+ const seen = new Set();
+ const out: string[] = [];
+ for (const value of input) {
+ if (!isValidFrameOrigin(value)) continue;
+ const key = value.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(value);
+ }
+ return out;
+}
+
+// In-memory cache. The proxy fires on every page navigation; reading the
+// registry JSON every time is fine but cheap to skip when nothing has
+// changed. Five seconds is short enough to make plugin install/uninstall
+// feel snappy without measurable overhead.
+let cachedAt = 0;
+let cachedOrigins: string[] = [];
+const CACHE_TTL_MS = 5_000;
+
+/**
+ * Returns the union of frame origins declared by every enabled plugin in
+ * the server-side registry, deduped and validated.
+ *
+ * Returns an empty array on any failure (missing file, parse error, …) so
+ * a broken registry only ever shrinks the CSP — never widens it.
+ */
+export async function getEnabledPluginFrameOrigins(): Promise {
+ const now = Date.now();
+ if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
+
+ try {
+ const registry = await getPluginRegistry();
+ const seen = new Set();
+ const out: string[] = [];
+ for (const plugin of registry.plugins) {
+ if (!plugin.enabled) continue;
+ const origins = sanitizeFrameOrigins(plugin.frameOrigins);
+ for (const o of origins) {
+ const key = o.toLowerCase();
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(o);
+ }
+ }
+ cachedOrigins = out;
+ cachedAt = now;
+ return out;
+ } catch {
+ cachedOrigins = [];
+ cachedAt = now;
+ return [];
+ }
+}
+
+/** Force the next call to re-read the registry. Used by install/uninstall. */
+export function invalidateFrameOriginsCache(): void {
+ cachedAt = 0;
+ cachedOrigins = [];
+}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts
index ab44bfab..788aa732 100644
--- a/lib/admin/plugin-registry.ts
+++ b/lib/admin/plugin-registry.ts
@@ -41,6 +41,11 @@ export interface ServerPlugin {
configSchema?: Record;
installedAt: string;
updatedAt: string;
+ /**
+ * Validated CSP origins (https-only, single-origin form) the plugin may
+ * embed. Merged into the host frame-src by the proxy.
+ */
+ frameOrigins?: string[];
}
export interface ServerTheme {
diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts
index 73c2e72b..3efd68d1 100644
--- a/lib/plugin-api.ts
+++ b/lib/plugin-api.ts
@@ -121,6 +121,7 @@ export interface PluginAPI {
registerSettingsSection: (section: SettingsSection) => Disposable;
registerComposerAction: (action: ComposerAction) => Disposable;
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
+ registerComposerSidebar: (widget: SidebarWidget) => Disposable;
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
registerNavigationRailItem: (component: React.ComponentType) => Disposable;
@@ -609,6 +610,11 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100);
},
+ registerComposerSidebar: (widget: SidebarWidget) => {
+ requirePermission(plugin, 'ui:composer-sidebar');
+ return registerSlot(plugin.id, 'composer-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
+ },
+
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100);
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index c036ed99..43e05d74 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -41,6 +41,14 @@ export interface PluginManifest {
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record>;
+ /**
+ * External origins this plugin may embed in iframes (e.g. for YouTube,
+ * Vimeo, Jitsi). Each entry is a single CSP origin like
+ * "https://www.youtube-nocookie.com"
+ * "https://*.example.com:8443"
+ * Validated at install time and merged into the host CSP `frame-src`.
+ */
+ frameOrigins?: string[];
}
export interface SettingFieldSchema {
@@ -101,6 +109,7 @@ export type SlotName =
| 'email-banner'
| 'email-footer'
| 'composer-toolbar'
+ | 'composer-sidebar'
| 'sidebar-widget'
| 'email-detail-sidebar'
| 'settings-section'
@@ -495,7 +504,8 @@ export const ALL_PERMISSIONS = [
'auth:observe',
'http:post',
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
- 'ui:composer-toolbar', 'ui:sidebar-widget', 'ui:settings-section',
+ 'ui:composer-toolbar', 'ui:composer-sidebar',
+ 'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
'ui:calendar-action', 'ui:admin-page',
'admin:config',
diff --git a/package-lock.json b/package-lock.json
index 894139a7..d4443ca0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"license": "AGPL-3.0-only",
"dependencies": {
"@tanstack/react-virtual": "^3.13.24",
diff --git a/package.json b/package.json
index 7b6ffd51..e8b18809 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bulwark-webmail",
- "version": "1.5.1",
+ "version": "1.5.2",
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
"author": "Bulwark Webmail ",
"license": "AGPL-3.0-only",
diff --git a/proxy.ts b/proxy.ts
index f7e087d6..c1efa1d7 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,10 +1,11 @@
import { type NextRequest, NextResponse } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
+import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
const intlMiddleware = createIntlMiddleware(routing);
-export function proxy(request: NextRequest) {
+export async function proxy(request: NextRequest) {
const nonce = crypto.randomUUID();
const isDev = process.env.NODE_ENV === "development";
@@ -16,6 +17,14 @@ export function proxy(request: NextRequest) {
const frameAncestors = process.env.ALLOWED_FRAME_ANCESTORS?.trim() || "'none'";
+ // Plugins may declare iframe origins they need (e.g. for embedded video).
+ // Each origin is validated at install time and re-validated here.
+ const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
+ const frameSrc =
+ pluginFrameOrigins.length > 0
+ ? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
+ : `frame-src 'self' blob:`;
+
const csp = [
`default-src 'self'`,
`script-src ${scriptSrc}`,
@@ -23,7 +32,7 @@ export function proxy(request: NextRequest) {
`img-src 'self' data: blob: https:`,
`font-src 'self'`,
`connect-src ${connectSrc}`,
- `frame-src 'self' blob:`,
+ frameSrc,
`object-src 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
@@ -81,4 +90,7 @@ export function proxy(request: NextRequest) {
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
+ // Read the plugin registry from disk to compute the dynamic frame-src
+ // allowlist. Edge runtime can't access the filesystem.
+ runtime: "nodejs",
};
diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts
index 55548872..46e8bfdf 100644
--- a/stores/plugin-store.ts
+++ b/stores/plugin-store.ts
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
// ─── Slot State ──────────────────────────────────────────────
const SLOT_NAMES: SlotName[] = [
- 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar',
+ 'toolbar-actions', 'email-banner', 'email-footer', 'composer-toolbar', 'composer-sidebar',
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
'calendar-event-actions', 'admin-plugin-page',
];