529 lines
21 KiB
TypeScript
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, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
}
|
|
|
|
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();
|
|
}
|