Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,528 @@
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user