import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PLUGIN_SETTING_ARRAY_MAX_LENGTH, PLUGIN_SETTING_KEY_MAX_LENGTH, PLUGIN_SETTING_OBJECT_MAX_KEYS, PLUGIN_SETTING_VALUE_MAX_BYTES, PLUGIN_SETTING_VALUE_MAX_DEPTH, PLUGIN_SETTING_VALUE_MAX_NODES, isPluginSettingValue, isSafePluginSettingKey, type PluginSettingValue, } from "../utils/pluginSettingsValidation"; export const PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION = 1; export const PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH = 256; export const PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH = 128; export const PLUGIN_SETTINGS_FRAME_MAX_KEY_LENGTH = PLUGIN_SETTING_KEY_MAX_LENGTH; export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_BYTES = PLUGIN_SETTING_VALUE_MAX_BYTES; export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_DEPTH = PLUGIN_SETTING_VALUE_MAX_DEPTH; export const PLUGIN_SETTINGS_FRAME_MAX_VALUE_NODES = PLUGIN_SETTING_VALUE_MAX_NODES; export const PLUGIN_SETTINGS_FRAME_MAX_ARRAY_LENGTH = PLUGIN_SETTING_ARRAY_MAX_LENGTH; export const PLUGIN_SETTINGS_FRAME_MAX_OBJECT_KEYS = PLUGIN_SETTING_OBJECT_MAX_KEYS; export const PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS = 32; // Window during which a second `load` for the same `srcdoc` document is treated // as a benign Chromium duplicate rather than a navigation. A real plugin-driven // self-navigation happens well after this window (it requires user/script // interaction), so security-relevant navigations are still quarantined. export const PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS = 1_000; const PLUGIN_SETTINGS_FRAME_PROTOCOL_STRING_PATTERN = /^[A-Za-z0-9._:-]+$/u; export const PLUGIN_SETTINGS_FRAME_ALLOW = [ "accelerometer 'none'", "ambient-light-sensor 'none'", "attribution-reporting 'none'", "autoplay 'none'", "bluetooth 'none'", "browsing-topics 'none'", "camera 'none'", "clipboard-read 'none'", "clipboard-write 'none'", "display-capture 'none'", "encrypted-media 'none'", "fullscreen 'none'", "gamepad 'none'", "geolocation 'none'", "gyroscope 'none'", "hid 'none'", "identity-credentials-get 'none'", "idle-detection 'none'", "local-fonts 'none'", "magnetometer 'none'", "microphone 'none'", "midi 'none'", "otp-credentials 'none'", "payment 'none'", "picture-in-picture 'none'", "publickey-credentials-get 'none'", "screen-wake-lock 'none'", "serial 'none'", "speaker-selection 'none'", "storage-access 'none'", "sync-xhr 'none'", "usb 'none'", "web-share 'none'", "xr-spatial-tracking 'none'", ].join("; "); export const PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME = "plugin-settings-channel"; export const PLUGIN_SETTINGS_FRAME_SANDBOX = "allow-scripts"; export const PLUGIN_SETTINGS_FRAME_CSP_NONCE_PLACEHOLDER = "__PLUGIN_SETTINGS_FRAME_SCRIPT_NONCE__"; export const PLUGIN_SETTINGS_FRAME_CSP = buildPluginSettingsFrameCsp(PLUGIN_SETTINGS_FRAME_CSP_NONCE_PLACEHOLDER); export function buildPluginSettingsFrameCsp(scriptNonce: string) { assertSafePluginSettingsFrameScriptNonce(scriptNonce); return [ "default-src 'none'", `script-src 'nonce-${scriptNonce}'`, "script-src-attr 'none'", // Self-contained signed settings HTML may use inline style attributes. "style-src 'unsafe-inline'", "img-src data:", "font-src data:", "connect-src 'none'", "frame-src 'none'", "child-src 'none'", "worker-src 'none'", "media-src 'none'", "object-src 'none'", "form-action 'none'", "base-uri 'none'", "navigate-to 'none'", ].join("; "); } type PluginSettingsReadyMessage = { readonly type: "plugin.settings.ready"; readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION; readonly pluginId: string; readonly channelId: string; readonly requestId?: string; }; type PluginSettingsGetMessage = { readonly type: "plugin.settings.get"; readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION; readonly pluginId: string; readonly channelId: string; readonly requestId: string; readonly key: string; }; type PluginSettingsSetMessage = { readonly type: "plugin.settings.set"; readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION; readonly pluginId: string; readonly channelId: string; readonly requestId: string; readonly key: string; readonly value: JsonLikeValue; }; export type PluginSettingsFrameMessage = PluginSettingsReadyMessage | PluginSettingsGetMessage | PluginSettingsSetMessage; export type PluginSettingsFrameResultMessage = | { readonly type: "plugin.settings.result"; readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION; readonly pluginId: string; readonly channelId: string; readonly requestId: string; readonly ok: true; readonly key?: string; readonly value?: JsonLikeValue; } | { readonly type: "plugin.settings.result"; readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION; readonly pluginId: string; readonly channelId: string; readonly requestId: string; readonly ok: false; readonly error: "settings_unavailable" | "settings_update_failed" | "invalid_payload"; readonly message: string; }; type PluginSettingsFrameResultError = Extract["error"]; type PluginSettingsFrameErrorMessages = Record; export const PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES: PluginSettingsFrameErrorMessages = { invalid_payload: "The plugin settings request is invalid.", settings_unavailable: "This setting is unavailable.", settings_update_failed: "Plugin settings could not be saved.", }; type JsonLikeValue = PluginSettingValue; interface PluginSettingsGetHandler { (key: string): JsonLikeValue | Promise; } interface PluginSettingsSetHandler { (key: string, value: JsonLikeValue): void | Promise; } export type PluginSettingsFrameProps = { readonly pluginId: string; readonly title: string; readonly trustedHtml: string; readonly settings?: Readonly>; readonly className?: string; readonly dataUiId?: string; readonly onReady?: (message: PluginSettingsReadyMessage) => void; readonly onGetSetting?: PluginSettingsGetHandler; readonly onSetSetting?: PluginSettingsSetHandler; readonly errorMessages?: Partial; readonly onInvalidMessage?: (reason: "wrong_origin" | "wrong_source" | "invalid_payload" | "unexpected_navigation") => void; }; export function PluginSettingsFrame({ pluginId, title, trustedHtml, settings, className, dataUiId, onReady, onGetSetting, onSetSetting, errorMessages, onInvalidMessage, }: PluginSettingsFrameProps) { const iframeRef = useRef(null); const initialLoadSeenRef = useRef(false); const initialLoadAtRef = useRef(0); const initialLoadWindowRef = useRef(null); const inFlightRequestIdsRef = useRef>(new Set()); const quarantinedFrameWindowRef = useRef(null); const [frameInstance, setFrameInstance] = useState(0); const [channelId, setChannelId] = useState(createPluginSettingsFrameChannelId); const [scriptNonce, setScriptNonce] = useState(createPluginSettingsFrameScriptNonce); const resolvedErrorMessages = useMemo(() => resolvePluginSettingsFrameErrorMessages(errorMessages), [errorMessages]); const settingsRef = useRef(settings); const onReadyRef = useRef(onReady); const onGetSettingRef = useRef(onGetSetting); const onSetSettingRef = useRef(onSetSetting); const onInvalidMessageRef = useRef(onInvalidMessage); const resolvedErrorMessagesRef = useRef(resolvedErrorMessages); settingsRef.current = settings; onReadyRef.current = onReady; onGetSettingRef.current = onGetSetting; onSetSettingRef.current = onSetSetting; onInvalidMessageRef.current = onInvalidMessage; resolvedErrorMessagesRef.current = resolvedErrorMessages; const srcDoc = useMemo( () => buildPluginSettingsFrameSrcDoc({ pluginId, trustedHtml, channelId, scriptNonce }), [channelId, pluginId, scriptNonce, trustedHtml] ); const lastSrcDocRef = useRef(srcDoc); if (lastSrcDocRef.current !== srcDoc) { lastSrcDocRef.current = srcDoc; initialLoadSeenRef.current = false; initialLoadAtRef.current = 0; initialLoadWindowRef.current = null; inFlightRequestIdsRef.current.clear(); quarantinedFrameWindowRef.current = null; } const handleFrameLoad = useCallback(() => { const loadedWindow = iframeRef.current?.contentWindow ?? null; if (!initialLoadSeenRef.current) { initialLoadSeenRef.current = true; initialLoadAtRef.current = Date.now(); initialLoadWindowRef.current = loadedWindow; return; } // Chromium can emit a spurious extra `load` for the same `srcdoc` document // (e.g. under layout/CPU pressure) shortly after the real initial load. // That duplicate keeps the same `contentWindow` and arrives within a short // coalescing window. Rotating the channel for it would silently invalidate // the live channelId mid-interaction (the iframe still references the old // one), so we ignore it. A genuine self-navigation discards the document and // always happens later than this window after user/script interaction, so it // still triggers quarantine below. const isSameWindowDuplicate = loadedWindow !== null && loadedWindow === initialLoadWindowRef.current; const isWithinCoalesceWindow = Date.now() - initialLoadAtRef.current <= PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS; if (isSameWindowDuplicate && isWithinCoalesceWindow) { return; } quarantinedFrameWindowRef.current = loadedWindow; initialLoadSeenRef.current = false; initialLoadAtRef.current = 0; initialLoadWindowRef.current = null; inFlightRequestIdsRef.current.clear(); onInvalidMessage?.("unexpected_navigation"); setChannelId(createPluginSettingsFrameChannelId()); setScriptNonce(createPluginSettingsFrameScriptNonce()); setFrameInstance((value) => value + 1); }, [onInvalidMessage]); useEffect(() => { const handleMessage = async (event: MessageEvent) => { const frameWindow = iframeRef.current?.contentWindow; if (quarantinedFrameWindowRef.current && event.source === quarantinedFrameWindowRef.current) { onInvalidMessageRef.current?.("wrong_source"); return; } if (!frameWindow || event.source !== frameWindow) { if (isPluginSettingsFrameProtocolEnvelope(event.data, pluginId)) { onInvalidMessageRef.current?.("wrong_source"); } return; } if (event.origin !== "null") { onInvalidMessageRef.current?.("wrong_origin"); return; } const message = parsePluginSettingsFrameMessage(event.data, pluginId); if (!message || message.channelId !== channelId) { const requestId = extractPluginSettingsFrameRequestId(event.data); if (requestId) { postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(requestId, "invalid_payload", resolvedErrorMessagesRef.current)); } onInvalidMessageRef.current?.("invalid_payload"); return; } if (message.type === "plugin.settings.ready") { onReadyRef.current?.(message); return; } const inFlightRequestIds = inFlightRequestIdsRef.current; if (inFlightRequestIds.has(message.requestId) || inFlightRequestIds.size >= PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS) { postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "invalid_payload", resolvedErrorMessagesRef.current)); onInvalidMessageRef.current?.("invalid_payload"); return; } inFlightRequestIds.add(message.requestId); try { if (message.type === "plugin.settings.get") { try { const onGetSettingCurrent = onGetSettingRef.current; const value = onGetSettingCurrent ? await onGetSettingCurrent(message.key) : settingsRef.current?.[message.key]; if (value === undefined || !isPluginSettingValue(value)) { postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_unavailable", resolvedErrorMessagesRef.current)); return; } postFrameResult(frameWindow, pluginId, channelId, { requestId: message.requestId, ok: true, key: message.key, value }); } catch { postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_unavailable", resolvedErrorMessagesRef.current)); } return; } try { const onSetSettingCurrent = onSetSettingRef.current; if (!onSetSettingCurrent) { postFrameResult(frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_update_failed", resolvedErrorMessagesRef.current)); return; } await onSetSettingCurrent(message.key, message.value); postFrameResult(frameWindow, pluginId, channelId, { requestId: message.requestId, ok: true, key: message.key, value: message.value }); } catch (error) { postFrameResult( frameWindow, pluginId, channelId, buildFrameErrorResult(message.requestId, "settings_update_failed", resolvedErrorMessagesRef.current, getPluginSettingsFrameErrorMessage(error)) ); } } finally { inFlightRequestIds.delete(message.requestId); } }; window.addEventListener("message", handleMessage); return () => window.removeEventListener("message", handleMessage); }, [channelId, pluginId]); return (