fix: give slot plugins the message, event or contact only with the matching read permission

This commit is contained in:
Linus Rath
2026-10-14 01:31:30 +02:00
parent 9bff740a89
commit c45533646e
5 changed files with 197 additions and 8 deletions
+1 -1
View File
@@ -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);
});
});
+16 -7
View File
@@ -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
+63
View File
@@ -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']);
});
});
+48
View File
@@ -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]