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', ];