fix: check plugin dropdown messages before the host draws them

This commit is contained in:
Linus Rath
2026-10-18 14:39:59 +02:00
parent be06956398
commit 1e992e6c7f
3 changed files with 172 additions and 3 deletions
+2 -2
View File
@@ -258,7 +258,7 @@ function FloatingDropdownPortal({
return createPortal(
<div
ref={dropdownRef}
id={dropdown.id}
id={`plugin-dropdown-${dropdown.id}`}
role="listbox"
aria-multiselectable={dropdown.multiple || undefined}
aria-label={dropdown.ariaLabel}
@@ -287,7 +287,7 @@ function FloatingDropdownPortal({
<div
key={opt.value}
ref={(el) => { 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}
@@ -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 });
});
});
+90 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
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;
}
}