Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/components/PluginSettingsFrame.tsx
T

529 lines
21 KiB
TypeScript

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<PluginSettingsFrameResultMessage, { readonly ok: false }>["error"];
type PluginSettingsFrameErrorMessages = Record<PluginSettingsFrameResultError, string>;
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<JsonLikeValue>;
}
interface PluginSettingsSetHandler {
(key: string, value: JsonLikeValue): void | Promise<void>;
}
export type PluginSettingsFrameProps = {
readonly pluginId: string;
readonly title: string;
readonly trustedHtml: string;
readonly settings?: Readonly<Record<string, JsonLikeValue>>;
readonly className?: string;
readonly dataUiId?: string;
readonly onReady?: (message: PluginSettingsReadyMessage) => void;
readonly onGetSetting?: PluginSettingsGetHandler;
readonly onSetSetting?: PluginSettingsSetHandler;
readonly errorMessages?: Partial<PluginSettingsFrameErrorMessages>;
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<HTMLIFrameElement>(null);
const initialLoadSeenRef = useRef(false);
const initialLoadAtRef = useRef(0);
const initialLoadWindowRef = useRef<Window | null>(null);
const inFlightRequestIdsRef = useRef<Set<string>>(new Set());
const quarantinedFrameWindowRef = useRef<Window | null>(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 (
<iframe
key={frameInstance}
ref={iframeRef}
title={title}
className={className}
data-ui-id={dataUiId ?? "ui.plugins.settings.frame"}
data-plugin-id={pluginId}
allow={PLUGIN_SETTINGS_FRAME_ALLOW}
referrerPolicy="no-referrer"
onLoad={handleFrameLoad}
sandbox={PLUGIN_SETTINGS_FRAME_SANDBOX}
suppressHydrationWarning
srcDoc={srcDoc}
/>
);
}
export function buildPluginSettingsFrameSrcDoc({
pluginId,
trustedHtml,
channelId,
scriptNonce = createPluginSettingsFrameScriptNonce(),
}: {
pluginId: string;
trustedHtml: string;
channelId: string;
scriptNonce?: string;
}) {
return [
"<!doctype html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(buildPluginSettingsFrameCsp(scriptNonce))}">`,
`<meta name="plugin-id" content="${escapeAttribute(pluginId)}">`,
`<meta name="plugin-settings-protocol" content="${PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION}">`,
`<meta name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}" content="${escapeAttribute(channelId)}">`,
'<meta name="viewport" content="width=device-width, initial-scale=1">',
"</head>",
"<body>",
addPluginSettingsFrameScriptNonce(trustedHtml, scriptNonce),
"</body>",
"</html>",
].join("");
}
export function addPluginSettingsFrameScriptNonce(trustedHtml: string, scriptNonce: string) {
return trustedHtml.replace(/<script\b([^>]*)>/giu, (match, rawAttributes: string) => {
const attributes = typeof rawAttributes === "string" ? rawAttributes : "";
if (hasHtmlAttribute(attributes, "src")) {
return match;
}
if (hasHtmlAttribute(attributes, "nonce")) {
return `<script${attributes.replace(/\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/iu, ` nonce="${escapeAttribute(scriptNonce)}"`)}>`;
}
return `<script nonce="${escapeAttribute(scriptNonce)}"${attributes}>`;
});
}
export function parsePluginSettingsFrameMessage(data: unknown, expectedPluginId?: string): PluginSettingsFrameMessage | null {
if (!isRecord(data) || data.version !== PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION) return null;
if (!isFrameProtocolString(data.pluginId, PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH)) return null;
if (expectedPluginId !== undefined && data.pluginId !== expectedPluginId) return null;
if (!isFrameProtocolString(data.channelId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH)) return null;
if (data.type === "plugin.settings.ready") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId"]) &&
isOptionalFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH)
? (data as PluginSettingsReadyMessage)
: null;
}
if (data.type === "plugin.settings.get") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId", "key"]) &&
isFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) &&
isSafePluginSettingKey(data.key)
? (data as PluginSettingsGetMessage)
: null;
}
if (data.type === "plugin.settings.set") {
return hasOnlyFrameMessageKeys(data, ["type", "version", "pluginId", "channelId", "requestId", "key", "value"]) &&
isFrameProtocolString(data.requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) &&
isSafePluginSettingKey(data.key) &&
isPluginSettingValue(data.value)
? (data as PluginSettingsSetMessage)
: null;
}
return null;
}
function extractPluginSettingsFrameRequestId(data: unknown): string | null {
if (!isRecord(data)) return null;
const requestId = data.requestId;
if (typeof requestId !== "string") return null;
return isFrameProtocolString(requestId, PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH) ? requestId : null;
}
function isPluginSettingsFrameProtocolEnvelope(data: unknown, expectedPluginId: string): boolean {
if (!isRecord(data)) return false;
return typeof data.type === "string" && data.type.startsWith("plugin.settings.") && data.pluginId === expectedPluginId;
}
function isOptionalFrameProtocolString(value: unknown, maxLength: number) {
return value === undefined || isFrameProtocolString(value, maxLength);
}
function isFrameProtocolString(value: unknown, maxLength: number) {
return typeof value === "string" && value.length > 0 && value.length <= maxLength && PLUGIN_SETTINGS_FRAME_PROTOCOL_STRING_PATTERN.test(value);
}
function hasOnlyFrameMessageKeys(value: Record<string, unknown>, allowedKeys: readonly string[]): boolean {
const allowed = new Set(allowedKeys);
return Object.keys(value).every((key) => allowed.has(key));
}
function postFrameResult(
frameWindow: Window,
pluginId: string,
channelId: string,
result:
| { readonly requestId: string; readonly ok: true; readonly key?: string; readonly value?: JsonLikeValue }
| { readonly requestId: string; readonly ok: false; readonly error: PluginSettingsFrameResultError; readonly message: string }
) {
// `srcDoc` without `allow-same-origin` has an opaque origin, so replies must
// target "*"; inbound validation above binds messages to that exact frame.
frameWindow.postMessage(
{
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
...result,
} satisfies PluginSettingsFrameResultMessage,
"*"
);
}
function resolvePluginSettingsFrameErrorMessages(errorMessages?: Partial<PluginSettingsFrameErrorMessages>): PluginSettingsFrameErrorMessages {
return { ...PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES, ...errorMessages };
}
function buildFrameErrorResult(requestId: string, error: PluginSettingsFrameResultError, errorMessages: PluginSettingsFrameErrorMessages, message?: string) {
return {
requestId,
ok: false,
error,
message: message ?? errorMessages[error],
} as const;
}
function getPluginSettingsFrameErrorMessage(error: unknown) {
return error instanceof Error && error.message.trim().length > 0 ? error.message : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function escapeAttribute(value: string) {
return value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
}
function hasHtmlAttribute(attributes: string, attributeName: string) {
return new RegExp(`(?:^|\\s)${attributeName}\\s*=`, "iu").test(attributes);
}
function assertSafePluginSettingsFrameScriptNonce(scriptNonce: string): void {
if (!/^[A-Za-z0-9+/_=-]{1,128}$/u.test(scriptNonce)) {
throw new Error("Plugin settings frame script nonce contains unsafe CSP characters.");
}
}
function createPluginSettingsFrameChannelId() {
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (bytes.some((byte) => byte !== 0)) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createPluginSettingsFrameScriptNonce() {
const bytes = new Uint8Array(16);
globalThis.crypto?.getRandomValues?.(bytes);
if (bytes.some((byte) => byte !== 0)) {
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
return createPluginSettingsFrameChannelId();
}