diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index c571f9a93..42ad79a0d 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -258,7 +258,7 @@ function FloatingDropdownPortal({ return createPortal(
{ optionRefs.current[i] = el; }} - id={`${dropdown.id}-opt-${i}`} + id={`plugin-dropdown-${dropdown.id}-opt-${i}`} role="option" aria-selected={isSelected} aria-disabled={opt.disabled} diff --git a/lib/__tests__/plugin-floating-dropdown-host.test.ts b/lib/__tests__/plugin-floating-dropdown-host.test.ts new file mode 100644 index 000000000..f2e570992 --- /dev/null +++ b/lib/__tests__/plugin-floating-dropdown-host.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; + +vi.mock('../plugin-sandbox/host-api', () => ({ + dispatchApiCall: vi.fn(async () => 'ok'), +})); + +import { createSlotInstance } from '../plugin-sandbox/host-bridge'; + +function plugin(): InstalledPlugin { + return { + id: 'p1', name: 'P', version: '1.0.0', author: '', description: '', type: 'hook', + permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, + }; +} + +const instances: Array<{ destroy(): void }> = []; +afterEach(() => { + for (const i of instances.splice(0)) i.destroy(); +}); + +// A slot iframe of 300x100 px; messages come from its own (null-origin) window. +function slot() { + const onFloatingDropdown = vi.fn(); + const inst = createSlotInstance({ + plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, + hostContainer: document.body, onResize: vi.fn(), onFloatingDropdown, + }); + instances.push(inst); + inst.iframe.getBoundingClientRect = () => ({ left: 0, top: 0, right: 300, bottom: 100, width: 300, height: 100, x: 0, y: 0, toJSON: () => ({}) }); + const post = (data: unknown) => + window.dispatchEvent(new MessageEvent('message', { data, origin: 'null', source: inst.iframe.contentWindow })); + return { onFloatingDropdown, post }; +} + +const goodRect = { left: 10, top: 20, bottom: 50, width: 200, height: 30 }; + +describe('floating dropdown messages from a plugin', () => { + it('passes a well-formed open message through', () => { + const { onFloatingDropdown, post } = slot(); + post({ + type: 'floating-dropdown-open', id: 'x-listbox', rect: goodRect, + options: [{ value: 'a', label: 'A' }], selectedValues: [], highlightedIndex: 0, + }); + expect(onFloatingDropdown).toHaveBeenCalledTimes(1); + expect(onFloatingDropdown.mock.calls[0][0]).toMatchObject({ id: 'x-listbox', rect: goodRect, options: [{ value: 'a', label: 'A' }] }); + }); + + it('drops an open message whose options are not a list, so the host never renders it', () => { + const { onFloatingDropdown, post } = slot(); + post({ type: 'floating-dropdown-open', id: 'x', rect: goodRect, options: 'boom', selectedValues: [], highlightedIndex: 0 }); + expect(onFloatingDropdown).not.toHaveBeenCalled(); + }); + + it('keeps only options with string labels and fills in missing selectedValues', () => { + const { onFloatingDropdown, post } = slot(); + post({ + type: 'floating-dropdown-open', id: 'x', rect: goodRect, highlightedIndex: 0, + options: [{ value: 'a', label: { $$typeof: 'x' } }, { value: 'b', label: 'B' }, null], + }); + const msg = onFloatingDropdown.mock.calls[0][0]; + expect(msg.options).toEqual([{ value: 'b', label: 'B' }]); + expect(msg.selectedValues).toEqual([]); + }); + + it('clamps the anchor rect to the plugin frame', () => { + const { onFloatingDropdown, post } = slot(); + post({ + type: 'floating-dropdown-open', id: 'x', options: [], selectedValues: [], highlightedIndex: -1, + rect: { left: -900, top: 5000, bottom: 6000, width: 99999, height: 1000 }, + }); + expect(onFloatingDropdown.mock.calls[0][0].rect).toEqual({ left: 0, top: 100, bottom: 100, width: 300, height: 0 }); + }); + + it('ignores a bad rect in an update but keeps the rest', () => { + const { onFloatingDropdown, post } = slot(); + post({ type: 'floating-dropdown-update', id: 'x', rect: { left: 'a' }, highlightedIndex: 2 }); + expect(onFloatingDropdown.mock.calls[0][0]).toEqual({ type: 'floating-dropdown-update', id: 'x', highlightedIndex: 2 }); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index deb34af95..6076898fc 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -18,10 +18,95 @@ import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg, FloatingDropdownOpenMsg, FloatingDropdownUpdateMsg, FloatingDropdownCloseMsg, FloatingDropdownSelectMsg, FloatingDropdownHighlightMsg, FloatingDropdownDismissMsg, + FloatingDropdownOption, FloatingDropdownRect, } from './protocol'; export type FloatingDropdownHostMsg = FloatingDropdownOpenMsg | FloatingDropdownUpdateMsg | FloatingDropdownCloseMsg; +// ─── Floating dropdown messages ────────────────────────────── + +const MAX_DROPDOWN_OPTIONS = 500; +const MAX_DROPDOWN_TEXT = 500; + +function str(v: unknown, max = MAX_DROPDOWN_TEXT): string | undefined { + return typeof v === 'string' ? v.slice(0, max) : undefined; +} + +function clamp(v: unknown, min: number, max: number): number | null { + if (typeof v !== 'number' || !Number.isFinite(v)) return null; + return Math.min(Math.max(v, min), Math.max(min, max)); +} + +function cleanRect(r: unknown, frameW: number, frameH: number): FloatingDropdownRect | null { + if (!r || typeof r !== 'object') return null; + const o = r as Record; + const left = clamp(o.left, 0, frameW); + const top = clamp(o.top, 0, frameH); + if (left === null || top === null) return null; + const bottom = clamp(o.bottom, top, frameH); + const width = clamp(o.width, 0, frameW - left); + if (bottom === null || width === null) return null; + return { left, top, bottom, width, height: bottom - top }; +} + +function cleanOptions(v: unknown): FloatingDropdownOption[] | null { + if (!Array.isArray(v)) return null; + const out: FloatingDropdownOption[] = []; + for (const item of v.slice(0, MAX_DROPDOWN_OPTIONS)) { + if (!item || typeof item !== 'object') continue; + const o = item as Record; + const value = str(o.value); + const label = str(o.label); + if (value === undefined || label === undefined) continue; + out.push(o.disabled === true ? { value, label, disabled: true } : { value, label }); + } + return out; +} + +function cleanValues(v: unknown): string[] | null { + if (!Array.isArray(v)) return null; + return v.slice(0, MAX_DROPDOWN_OPTIONS).filter((x): x is string => typeof x === 'string'); +} + +/** + * Check a floating-dropdown message from a plugin before the host renders + * it in its own DOM: drop messages with a bad id or shape, cap sizes, keep + * only string labels, and clamp the anchor rect (iframe coordinates) to the + * frame so a plugin can't put a host-drawn popup anywhere on the page. + */ +export function sanitizeFloatingDropdownMsg( + msg: FloatingDropdownHostMsg, + frameW: number, + frameH: number, +): FloatingDropdownHostMsg | null { + const id = str(msg.id, 200); + if (!id) return null; + if (msg.type === 'floating-dropdown-close') return { type: msg.type, id }; + const highlighted = typeof msg.highlightedIndex === 'number' && Number.isInteger(msg.highlightedIndex) + ? msg.highlightedIndex : undefined; + if (msg.type === 'floating-dropdown-open') { + const rect = cleanRect(msg.rect, frameW, frameH); + const options = cleanOptions(msg.options); + if (!rect || !options) return null; + return { + type: msg.type, id, rect, options, + multiple: msg.multiple === true, + selectedValues: cleanValues(msg.selectedValues) ?? [], + singleValue: str(msg.singleValue), + highlightedIndex: highlighted ?? -1, + ariaLabel: str(msg.ariaLabel), + }; + } + const out: FloatingDropdownUpdateMsg = { type: 'floating-dropdown-update', id }; + if (msg.rect !== undefined) { const r = cleanRect(msg.rect, frameW, frameH); if (r) out.rect = r; } + if (msg.options !== undefined) { const o = cleanOptions(msg.options); if (o) out.options = o; } + if (msg.selectedValues !== undefined) { const v = cleanValues(msg.selectedValues); if (v) out.selectedValues = v; } + if (typeof msg.multiple === 'boolean') out.multiple = msg.multiple; + if (typeof msg.singleValue === 'string') out.singleValue = str(msg.singleValue); + if (highlighted !== undefined) out.highlightedIndex = highlighted; + return out; +} + // ─── Callback marshalling ──────────────────────────────────── /** @@ -375,7 +460,11 @@ export class SandboxInstance { case 'floating-dropdown-open': case 'floating-dropdown-update': case 'floating-dropdown-close': { - this.slotFloatingDropdownCb?.(msg); + // The host draws this dropdown in its own DOM, so a malformed message + // must not crash the app, and the anchor must stay inside the frame. + const frame = this.iframe.getBoundingClientRect(); + const clean = sanitizeFloatingDropdownMsg(msg, frame.width, frame.height); + if (clean) this.slotFloatingDropdownCb?.(clean); return; } }