mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: give slot plugins the message, event or contact only with the matching read permission
This commit is contained in:
@@ -6103,7 +6103,7 @@ export function EmailViewer({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
|
||||
<PluginSlot name="email-detail-sidebar" extraProps={{ email: emailToReadView(email) }} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import type { InstalledPlugin } from '@/lib/plugin-types';
|
||||
|
||||
const evaluateShouldShow = vi.fn(async () => true);
|
||||
let activePlugin: InstalledPlugin;
|
||||
let hasShouldShow = false;
|
||||
|
||||
vi.mock('@/lib/plugin-sandbox/registry', () => ({
|
||||
get: () => ({
|
||||
plugin: activePlugin,
|
||||
code: '',
|
||||
tier: 'untrusted',
|
||||
background: { evaluateShouldShow },
|
||||
slotOffers: [{ name: 'email-banner', hasShouldShow, order: 100 }],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSlotInstance = vi.fn(() => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
|
||||
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
|
||||
createSlotInstance: (...args: unknown[]) => createSlotInstance(...(args as [])),
|
||||
}));
|
||||
vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
|
||||
|
||||
import { PluginIframeSlot } from '../plugin-iframe-slot';
|
||||
|
||||
function plugin(perms: string[]): InstalledPlugin {
|
||||
return {
|
||||
id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
const EMAIL = { id: 'm1', text: 'confidential body' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hasShouldShow = false;
|
||||
});
|
||||
|
||||
describe('PluginIframeSlot', () => {
|
||||
it('mounts a banner-only plugin without the opened message', async () => {
|
||||
activePlugin = plugin(['ui:email-banner']);
|
||||
render(<PluginIframeSlot pluginId="p1" slot="email-banner" extraProps={{ email: EMAIL }} />);
|
||||
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record<string, unknown> };
|
||||
expect(opts.extraProps.email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not show the message to shouldShow either', async () => {
|
||||
activePlugin = plugin(['ui:email-banner']);
|
||||
hasShouldShow = true;
|
||||
render(<PluginIframeSlot pluginId="p1" slot="email-banner" extraProps={{ email: EMAIL }} />);
|
||||
|
||||
await waitFor(() => expect(evaluateShouldShow).toHaveBeenCalled());
|
||||
expect((evaluateShouldShow.mock.calls[0] as unknown[])[1]).toEqual({});
|
||||
});
|
||||
|
||||
it('hands the message to a plugin that may read mail', async () => {
|
||||
activePlugin = plugin(['ui:email-banner', 'email:read']);
|
||||
render(<PluginIframeSlot pluginId="p1" slot="email-banner" extraProps={{ email: EMAIL }} />);
|
||||
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalled());
|
||||
const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record<string, unknown> };
|
||||
expect(opts.extraProps.email).toEqual(EMAIL);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,11 @@
|
||||
// null-origin context; its height is pushed back via postMessage and applied
|
||||
// to a wrapper <div>.
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SlotName } from '@/lib/plugin-types';
|
||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
||||
import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
|
||||
import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
|
||||
@@ -26,6 +27,14 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
// null = pending, true/false = decided
|
||||
const [show, setShow] = useState<boolean | null>(null);
|
||||
|
||||
// What the plugin may see of the surface the slot sits on. Everything the
|
||||
// plugin receives (shouldShow context, mount props, updates) goes through
|
||||
// this, so a ui:* permission never hands over mail, events or contacts.
|
||||
const props = useMemo(() => {
|
||||
const active = getActivePlugin(pluginId);
|
||||
return active ? slotPropsFor(active.plugin, slot, extraProps ?? {}) : {};
|
||||
}, [pluginId, slot, extraProps]);
|
||||
|
||||
// Decide whether to mount based on the plugin's shouldShow (background-side).
|
||||
useEffect(() => {
|
||||
const active = getActivePlugin(pluginId);
|
||||
@@ -35,11 +44,11 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
if (!offer.hasShouldShow) { setShow(true); return; }
|
||||
let cancelled = false;
|
||||
active.background
|
||||
.evaluateShouldShow(slot, extraProps ?? {})
|
||||
.evaluateShouldShow(slot, props)
|
||||
.then((s) => { if (!cancelled) setShow(s); })
|
||||
.catch(() => { if (!cancelled) setShow(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [pluginId, slot, extraProps]);
|
||||
}, [pluginId, slot, props]);
|
||||
|
||||
// Spawn / tear down the slot iframe.
|
||||
useEffect(() => {
|
||||
@@ -53,7 +62,7 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
code: active.code,
|
||||
locale,
|
||||
tier: active.tier,
|
||||
extraProps: extraProps ?? {},
|
||||
extraProps: props,
|
||||
hostContainer: wrapperRef.current,
|
||||
onResize: (h) => setHeight(h),
|
||||
});
|
||||
@@ -62,15 +71,15 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
||||
try { inst.destroy(); } catch { /* ignore */ }
|
||||
instanceRef.current = null;
|
||||
};
|
||||
// We intentionally don't depend on extraProps here - propagating prop
|
||||
// We intentionally don't depend on props here - propagating prop
|
||||
// changes happens via postMessage below to avoid iframe churn.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show, pluginId, slot]);
|
||||
|
||||
// Push prop updates without remount.
|
||||
useEffect(() => {
|
||||
instanceRef.current?.updateProps(extraProps ?? {});
|
||||
}, [extraProps]);
|
||||
instanceRef.current?.updateProps(props);
|
||||
}, [props]);
|
||||
|
||||
// Re-theme the live slot iframe when the host theme changes (dark/light
|
||||
// toggle or custom theme switch), without tearing down the iframe. Reacting
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { slotPropsFor } from '../plugin-sandbox/permissions';
|
||||
|
||||
function plugin(perms: Permission[]): InstalledPlugin {
|
||||
return {
|
||||
id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
|
||||
permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
|
||||
enabled: true, status: 'running', settings: {},
|
||||
};
|
||||
}
|
||||
|
||||
const EMAIL = { id: 'm1', subject: 'Salary review', text: 'confidential body' };
|
||||
|
||||
describe('slot props follow read permissions', () => {
|
||||
it('withholds the opened message from an email-banner plugin without email:read', () => {
|
||||
// ui:email-banner only lets a plugin draw a banner. Handing it the body,
|
||||
// headers and source would make it as good as email:read.
|
||||
const props = slotPropsFor(plugin(['ui:email-banner']), 'email-banner', { email: EMAIL });
|
||||
|
||||
expect(props.email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the message once the plugin also holds email:read', () => {
|
||||
const props = slotPropsFor(plugin(['ui:email-banner', 'email:read']), 'email-banner', { email: EMAIL });
|
||||
|
||||
expect(props.email).toEqual(EMAIL);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['email-details-section', 'email', 'email:read'],
|
||||
['email-detail-sidebar', 'email', 'email:read'],
|
||||
['calendar-event-actions', 'eventData', 'calendar:read'],
|
||||
['contact-cryptokeys', 'cryptoKeys', 'contacts:read'],
|
||||
['app-top-banner', 'username', 'account:read'],
|
||||
['app-top-banner', 'serverUrl', 'account:read'],
|
||||
] as const)('%s.%s needs %s', (slot, key, perm) => {
|
||||
const without = slotPropsFor(plugin([]), slot, { [key]: 'value' });
|
||||
const withPerm = slotPropsFor(plugin([perm]), slot, { [key]: 'value' });
|
||||
|
||||
expect(without[key]).toBeUndefined();
|
||||
expect(withPerm[key]).toBe('value');
|
||||
});
|
||||
|
||||
it('drops props a slot does not declare', () => {
|
||||
const props = slotPropsFor(plugin(['email:read']), 'email-footer', { email: EMAIL, extra: 1 });
|
||||
|
||||
expect(props).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps host callbacks that are part of the slot contract', () => {
|
||||
const onAttach = () => {};
|
||||
const props = slotPropsFor(plugin([]), 'composer-attachment-source', { onAttach });
|
||||
|
||||
expect(props.onAttach).toBe(onAttach);
|
||||
});
|
||||
|
||||
it('passes a plugin dialog its own props unchanged', () => {
|
||||
const props = slotPropsFor(plugin([]), 'plugin-dialog', { anything: { nested: true }, onResult: () => {} });
|
||||
|
||||
expect(Object.keys(props).sort()).toEqual(['anything', 'onResult']);
|
||||
});
|
||||
});
|
||||
@@ -288,6 +288,54 @@ const SLOT_PERMISSIONS: Record<SlotName, readonly Permission[]> = {
|
||||
'plugin-dialog': ['ui:observe'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The props each slot may hand its plugin, and the permission each needs
|
||||
* (null: none beyond the slot's own). A ui:* permission only lets a plugin
|
||||
* draw somewhere; the message, event, contact or account the slot sits on is
|
||||
* data, and stays behind the matching read permission. Props not listed here
|
||||
* are dropped, so a new prop at a call site is withheld until it is added.
|
||||
*/
|
||||
const SLOT_PROPS: Record<SlotName, Record<string, Permission | null> | '*'> = {
|
||||
'toolbar-actions': {},
|
||||
'app-top-banner': { username: 'account:read', serverUrl: 'account:read' },
|
||||
'email-banner': { email: 'email:read' },
|
||||
'email-footer': {},
|
||||
'composer-toolbar': {},
|
||||
'composer-sidebar': {},
|
||||
'composer-sidebar-right': {},
|
||||
'composer-attachment-source': { onAttach: null },
|
||||
'sidebar-widget': {},
|
||||
'email-detail-sidebar': { email: 'email:read' },
|
||||
'email-details-section': { email: 'email:read', category: null },
|
||||
'settings-section': {},
|
||||
'context-menu-email': {},
|
||||
'navigation-rail-bottom': {},
|
||||
'calendar-event-actions': { eventData: 'calendar:read', setVirtualLocation: null },
|
||||
'admin-plugin-page': {},
|
||||
'contact-cryptokeys': { cryptoKeys: 'contacts:read' },
|
||||
'attachment-actions': { attachment: null },
|
||||
// The plugin chose these props itself when it opened the dialog.
|
||||
'plugin-dialog': '*',
|
||||
};
|
||||
|
||||
export function slotPropsFor(
|
||||
plugin: InstalledPlugin,
|
||||
slot: SlotName,
|
||||
props: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allowed = SLOT_PROPS[slot];
|
||||
if (allowed === '*') return props;
|
||||
if (!allowed) return {};
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
|
||||
const perm = allowed[key];
|
||||
if (perm && !pluginHasPermission(plugin, perm)) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean {
|
||||
const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot)
|
||||
? SLOT_PERMISSIONS[slot as SlotName]
|
||||
|
||||
Reference in New Issue
Block a user