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;
}
}