feat: composer-sidebar slot + plugin-declared frame-src origins

This commit is contained in:
Linus Rath
2026-04-25 18:40:54 +02:00
parent 5aa9b1d5f9
commit fe1d4861bb
15 changed files with 304 additions and 11 deletions
+104
View File
@@ -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']);
});
});
+1
View File
@@ -47,6 +47,7 @@ function resetStore() {
'email-banner': [],
'email-footer': [],
'composer-toolbar': [],
'composer-sidebar': [],
'sidebar-widget': [],
'email-detail-sidebar': [],
'settings-section': [],
+102
View File
@@ -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<string>();
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<string[]> {
const now = Date.now();
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
try {
const registry = await getPluginRegistry();
const seen = new Set<string>();
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 = [];
}
+5
View File
@@ -41,6 +41,11 @@ export interface ServerPlugin {
configSchema?: Record<string, PluginConfigField>;
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 {
+6
View File
@@ -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<Record<string, unknown>>, widget.order ?? 100);
},
registerComposerSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:composer-sidebar');
return registerSlot(plugin.id, 'composer-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
},
registerDetailSidebar: (widget: SidebarWidget) => {
requirePermission(plugin, 'ui:sidebar-widget');
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
+11 -1
View File
@@ -41,6 +41,14 @@ export interface PluginManifest {
* so plugins can use api.i18n.t() without calling addTranslations() first.
*/
locales?: Record<string, Record<string, string>>;
/**
* 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',