feat: composer-sidebar slot + plugin-declared frame-src origins
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 1.5.1 (2026-04-25)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
type ServerTheme,
|
type ServerTheme,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
import JSZip from 'jszip';
|
import JSZip from 'jszip';
|
||||||
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
|
||||||
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
import { sanitizeThemeCSS, validateThemeCSSSafety } from '@/lib/theme-loader';
|
||||||
@@ -226,6 +230,22 @@ export async function POST(request: NextRequest) {
|
|||||||
warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
|
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 = {
|
const plugin: ServerPlugin = {
|
||||||
id: (manifest.id as string) || slug,
|
id: (manifest.id as string) || slug,
|
||||||
name: (manifest.name as string) || slug,
|
name: (manifest.name as string) || slug,
|
||||||
@@ -238,10 +258,14 @@ export async function POST(request: NextRequest) {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
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 });
|
return NextResponse.json({ success: true, plugin, warnings });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
deletePlugin as removePlugin,
|
deletePlugin as removePlugin,
|
||||||
type ServerPlugin,
|
type ServerPlugin,
|
||||||
} from '@/lib/admin/plugin-registry';
|
} from '@/lib/admin/plugin-registry';
|
||||||
|
import {
|
||||||
|
sanitizeFrameOrigins,
|
||||||
|
invalidateFrameOriginsCache,
|
||||||
|
} from '@/lib/admin/csp-frame-origins';
|
||||||
|
|
||||||
// Server-side extraction using the same validation logic
|
// Server-side extraction using the same validation logic
|
||||||
// ZIP parsing needs to happen on the server for admin-uploaded plugins
|
// 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 now = new Date().toISOString();
|
||||||
const plugin: ServerPlugin = {
|
const plugin: ServerPlugin = {
|
||||||
id: manifest.id as string,
|
id: manifest.id as string,
|
||||||
@@ -166,12 +172,16 @@ export async function POST(request: NextRequest) {
|
|||||||
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
...(manifest.configSchema && typeof manifest.configSchema === 'object'
|
||||||
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(declaredFrameOrigins.length > 0
|
||||||
|
? { frameOrigins: declaredFrameOrigins }
|
||||||
|
: {}),
|
||||||
installedAt: now,
|
installedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await savePlugin(plugin, code);
|
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 });
|
return NextResponse.json({ plugin });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -209,6 +219,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
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);
|
await auditLog('plugin.update', { id, ...updates }, ip);
|
||||||
return NextResponse.json({ plugin: updated });
|
return NextResponse.json({ plugin: updated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -238,6 +253,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateFrameOriginsCache();
|
||||||
await auditLog('plugin.delete', { id }, ip);
|
await auditLog('plugin.delete', { id }, ip);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1100,8 +1100,13 @@ export function EmailComposer({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className={cn("flex h-full bg-background", className)}>
|
||||||
|
<PluginSlot
|
||||||
|
name="composer-sidebar"
|
||||||
|
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn("flex flex-col h-full bg-background relative", className)}
|
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
|
||||||
data-tour="composer"
|
data-tour="composer"
|
||||||
onDragEnter={handleDragEnter}
|
onDragEnter={handleDragEnter}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -1674,6 +1679,7 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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-banner': [],
|
||||||
'email-footer': [],
|
'email-footer': [],
|
||||||
'composer-toolbar': [],
|
'composer-toolbar': [],
|
||||||
|
'composer-sidebar': [],
|
||||||
'sidebar-widget': [],
|
'sidebar-widget': [],
|
||||||
'email-detail-sidebar': [],
|
'email-detail-sidebar': [],
|
||||||
'settings-section': [],
|
'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>;
|
configSchema?: Record<string, PluginConfigField>;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
updatedAt: 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 {
|
export interface ServerTheme {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export interface PluginAPI {
|
|||||||
registerSettingsSection: (section: SettingsSection) => Disposable;
|
registerSettingsSection: (section: SettingsSection) => Disposable;
|
||||||
registerComposerAction: (action: ComposerAction) => Disposable;
|
registerComposerAction: (action: ComposerAction) => Disposable;
|
||||||
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
registerSidebarWidget: (widget: SidebarWidget) => Disposable;
|
||||||
|
registerComposerSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
registerDetailSidebar: (widget: SidebarWidget) => Disposable;
|
||||||
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
registerContextMenuItem: (item: ContextMenuItem) => Disposable;
|
||||||
registerNavigationRailItem: (component: React.ComponentType) => 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);
|
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) => {
|
registerDetailSidebar: (widget: SidebarWidget) => {
|
||||||
requirePermission(plugin, 'ui:sidebar-widget');
|
requirePermission(plugin, 'ui:sidebar-widget');
|
||||||
return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType<Record<string, unknown>>, widget.order ?? 100);
|
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.
|
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||||
*/
|
*/
|
||||||
locales?: Record<string, Record<string, string>>;
|
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 {
|
export interface SettingFieldSchema {
|
||||||
@@ -101,6 +109,7 @@ export type SlotName =
|
|||||||
| 'email-banner'
|
| 'email-banner'
|
||||||
| 'email-footer'
|
| 'email-footer'
|
||||||
| 'composer-toolbar'
|
| 'composer-toolbar'
|
||||||
|
| 'composer-sidebar'
|
||||||
| 'sidebar-widget'
|
| 'sidebar-widget'
|
||||||
| 'email-detail-sidebar'
|
| 'email-detail-sidebar'
|
||||||
| 'settings-section'
|
| 'settings-section'
|
||||||
@@ -495,7 +504,8 @@ export const ALL_PERMISSIONS = [
|
|||||||
'auth:observe',
|
'auth:observe',
|
||||||
'http:post',
|
'http:post',
|
||||||
'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer',
|
'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:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||||
'ui:calendar-action', 'ui:admin-page',
|
'ui:calendar-action', 'ui:admin-page',
|
||||||
'admin:config',
|
'admin:config',
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.24",
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bulwark-webmail",
|
"name": "bulwark-webmail",
|
||||||
"version": "1.5.1",
|
"version": "1.5.2",
|
||||||
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
"description": "Bulwark Webmail - a modern webmail client built for Stalwart Mail Server",
|
||||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import createIntlMiddleware from "next-intl/middleware";
|
import createIntlMiddleware from "next-intl/middleware";
|
||||||
import { routing } from "./i18n/routing";
|
import { routing } from "./i18n/routing";
|
||||||
|
import { getEnabledPluginFrameOrigins } from "./lib/admin/csp-frame-origins";
|
||||||
|
|
||||||
const intlMiddleware = createIntlMiddleware(routing);
|
const intlMiddleware = createIntlMiddleware(routing);
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const nonce = crypto.randomUUID();
|
const nonce = crypto.randomUUID();
|
||||||
const isDev = process.env.NODE_ENV === "development";
|
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'";
|
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 = [
|
const csp = [
|
||||||
`default-src 'self'`,
|
`default-src 'self'`,
|
||||||
`script-src ${scriptSrc}`,
|
`script-src ${scriptSrc}`,
|
||||||
@@ -23,7 +32,7 @@ export function proxy(request: NextRequest) {
|
|||||||
`img-src 'self' data: blob: https:`,
|
`img-src 'self' data: blob: https:`,
|
||||||
`font-src 'self'`,
|
`font-src 'self'`,
|
||||||
`connect-src ${connectSrc}`,
|
`connect-src ${connectSrc}`,
|
||||||
`frame-src 'self' blob:`,
|
frameSrc,
|
||||||
`object-src 'none'`,
|
`object-src 'none'`,
|
||||||
`base-uri 'self'`,
|
`base-uri 'self'`,
|
||||||
`form-action 'self'`,
|
`form-action 'self'`,
|
||||||
@@ -81,4 +90,7 @@ export function proxy(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next|.*\\..*).*)"],
|
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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { apiFetch } from '@/lib/browser-navigation';
|
|||||||
// ─── Slot State ──────────────────────────────────────────────
|
// ─── Slot State ──────────────────────────────────────────────
|
||||||
|
|
||||||
const SLOT_NAMES: SlotName[] = [
|
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',
|
'sidebar-widget', 'email-detail-sidebar', 'settings-section', 'context-menu-email', 'navigation-rail-bottom',
|
||||||
'calendar-event-actions', 'admin-plugin-page',
|
'calendar-event-actions', 'admin-plugin-page',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user