mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: check plugin dropdown messages before the host draws them
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user