};
+ expect(opts.extraProps.email).toEqual(EMAIL);
+ });
+});
diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx
index b2b1e5b23..8c372df27 100644
--- a/components/plugins/plugin-iframe-slot.tsx
+++ b/components/plugins/plugin-iframe-slot.tsx
@@ -6,10 +6,11 @@
// null-origin context; its height is pushed back via postMessage and applied
// to a wrapper .
-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(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
diff --git a/lib/__tests__/plugin-slot-props.test.ts b/lib/__tests__/plugin-slot-props.test.ts
new file mode 100644
index 000000000..2e1ac4e02
--- /dev/null
+++ b/lib/__tests__/plugin-slot-props.test.ts
@@ -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']);
+ });
+});
diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts
index 1f298b51e..a0ddfa85d 100644
--- a/lib/plugin-sandbox/permissions.ts
+++ b/lib/plugin-sandbox/permissions.ts
@@ -288,6 +288,54 @@ const SLOT_PERMISSIONS: Record = {
'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 | '*'> = {
+ '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,
+): Record {
+ const allowed = SLOT_PROPS[slot];
+ if (allowed === '*') return props;
+ if (!allowed) return {};
+ const out: Record = {};
+ 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]