diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 07463f525..7da9207fd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -6103,7 +6103,7 @@ export function EmailViewer({
- +
diff --git a/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx new file mode 100644 index 000000000..46e7ede21 --- /dev/null +++ b/components/plugins/__tests__/plugin-iframe-slot-props.test.tsx @@ -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(); + + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record }; + expect(opts.extraProps.email).toBeUndefined(); + }); + + it('does not show the message to shouldShow either', async () => { + activePlugin = plugin(['ui:email-banner']); + hasShouldShow = true; + render(); + + 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(); + + await waitFor(() => expect(createSlotInstance).toHaveBeenCalled()); + const opts = (createSlotInstance.mock.calls[0] as unknown[])[0] as { extraProps: Record }; + 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]