feat: composer-sidebar slot + plugin-declared frame-src origins
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ function resetStore() {
|
||||
'email-banner': [],
|
||||
'email-footer': [],
|
||||
'composer-toolbar': [],
|
||||
'composer-sidebar': [],
|
||||
'sidebar-widget': [],
|
||||
'email-detail-sidebar': [],
|
||||
'settings-section': [],
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user