1038 lines
37 KiB
TypeScript
1038 lines
37 KiB
TypeScript
import "@testing-library/jest-dom";
|
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
PLUGIN_SETTINGS_FRAME_ALLOW,
|
|
PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME,
|
|
PLUGIN_SETTINGS_FRAME_CSP,
|
|
PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES,
|
|
PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS,
|
|
PLUGIN_SETTINGS_FRAME_MAX_ARRAY_LENGTH,
|
|
PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS,
|
|
PLUGIN_SETTINGS_FRAME_MAX_KEY_LENGTH,
|
|
PLUGIN_SETTINGS_FRAME_MAX_OBJECT_KEYS,
|
|
PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH,
|
|
PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH,
|
|
PLUGIN_SETTINGS_FRAME_MAX_VALUE_BYTES,
|
|
PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
|
|
PLUGIN_SETTINGS_FRAME_SANDBOX,
|
|
PluginSettingsFrame,
|
|
addPluginSettingsFrameScriptNonce,
|
|
buildPluginSettingsFrameCsp,
|
|
buildPluginSettingsFrameSrcDoc,
|
|
parsePluginSettingsFrameMessage,
|
|
} from "./PluginSettingsFrame";
|
|
import { readRepoFile } from "~/utils/testing/repoPaths";
|
|
|
|
const TEST_CHANNEL_ID = "test-channel";
|
|
const TEST_SCRIPT_NONCE = "test-script-nonce";
|
|
|
|
function dispatchFrameMessage(iframe: HTMLIFrameElement, data: unknown, origin = "null") {
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
act(() => {
|
|
window.dispatchEvent(new MessageEvent("message", { data, origin, source }));
|
|
});
|
|
return source;
|
|
}
|
|
|
|
function getFrameChannelId(iframe: Element) {
|
|
const srcDoc = iframe.getAttribute("srcdoc") ?? "";
|
|
const match = srcDoc.match(new RegExp(`name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}" content="([^"]+)"`, "u"));
|
|
if (!match?.[1]) throw new Error("plugin settings frame channel missing");
|
|
return match[1];
|
|
}
|
|
|
|
describe("PluginSettingsFrame", () => {
|
|
it("keeps default frame error copy aligned with the English locale", () => {
|
|
const en = JSON.parse(readRepoFile("apps/web/public/locales/en/translations.json")) as {
|
|
settings: { plugins: { settings: { frameErrors: Record<string, string> } } };
|
|
};
|
|
|
|
expect(PLUGIN_SETTINGS_FRAME_DEFAULT_ERROR_MESSAGES).toEqual(en.settings.plugins.settings.frameErrors);
|
|
});
|
|
|
|
it("renders a sandboxed opaque-origin iframe with an embedded CSP", () => {
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main><h1>Settings</h1></main>" />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings");
|
|
expect(iframe).toHaveAttribute("data-ui-id", "ui.plugins.settings.frame");
|
|
expect(iframe).toHaveAttribute("data-plugin-id", "com.acme.settings");
|
|
expect(iframe).toHaveAttribute("referrerpolicy", "no-referrer");
|
|
expect(iframe).toHaveAttribute("allow", PLUGIN_SETTINGS_FRAME_ALLOW);
|
|
expect(iframe).toHaveAttribute("sandbox", PLUGIN_SETTINGS_FRAME_SANDBOX);
|
|
expect(iframe).not.toHaveAttribute("sandbox", expect.stringContaining("allow-forms"));
|
|
expect(iframe).not.toHaveAttribute("sandbox", expect.stringContaining("allow-same-origin"));
|
|
expect(iframe).not.toHaveAttribute("src");
|
|
expect(iframe).toHaveAttribute("srcdoc", expect.stringContaining('http-equiv="Content-Security-Policy"'));
|
|
expect(iframe).toHaveAttribute("srcdoc", expect.stringContaining("plugin-settings-protocol"));
|
|
expect(iframe).toHaveAttribute("srcdoc", expect.stringContaining(`name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}"`));
|
|
});
|
|
|
|
it("allows routes to provide a stable UI selector", () => {
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" dataUiId="ui.custom.plugin.frame" />);
|
|
|
|
expect(screen.getByTitle("Acme settings")).toHaveAttribute("data-ui-id", "ui.custom.plugin.frame");
|
|
});
|
|
|
|
it("keeps one message listener while reading fresh settings after rerenders", async () => {
|
|
const addEventListener = vi.spyOn(window, "addEventListener");
|
|
const removeEventListener = vi.spyOn(window, "removeEventListener");
|
|
const { rerender, unmount } = render(
|
|
<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" settings={{ theme: "light" }} />
|
|
);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
rerender(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" settings={{ theme: "dark" }} />);
|
|
|
|
expect(addEventListener.mock.calls.filter(([type]) => type === "message")).toHaveLength(1);
|
|
expect(removeEventListener.mock.calls.filter(([type]) => type === "message")).toHaveLength(0);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "get-theme",
|
|
key: "theme",
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "get-theme",
|
|
ok: true,
|
|
key: "theme",
|
|
value: "dark",
|
|
},
|
|
"*"
|
|
)
|
|
);
|
|
|
|
unmount();
|
|
expect(removeEventListener.mock.calls.filter(([type]) => type === "message")).toHaveLength(1);
|
|
});
|
|
|
|
it("builds srcDoc with plugin identity and escaped CSP attributes", () => {
|
|
const srcDoc = buildPluginSettingsFrameSrcDoc({
|
|
pluginId: 'com.acme."quoted"',
|
|
trustedHtml: "<main></main>",
|
|
channelId: 'channel-"quoted"',
|
|
scriptNonce: TEST_SCRIPT_NONCE,
|
|
});
|
|
|
|
expect(srcDoc).toContain(`content="${buildPluginSettingsFrameCsp(TEST_SCRIPT_NONCE).replace(/"/gu, """)}"`);
|
|
expect(srcDoc).toContain('content="com.acme."quoted""');
|
|
expect(srcDoc).toContain(`name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}" content="channel-"quoted""`);
|
|
expect(srcDoc).toContain(`<meta name="plugin-settings-protocol" content="${PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION}">`);
|
|
});
|
|
|
|
it("nonces bundled inline scripts without authorizing external scripts", () => {
|
|
const trustedHtml = [
|
|
"<script>window.ready = true;</script>",
|
|
'<script type="module" nonce="plugin-authored">window.moduleReady = true;</script>',
|
|
'<script src="https://evil.example/settings.js"></script>',
|
|
'<button onclick="window.clicked = true">Click</button>',
|
|
].join("");
|
|
|
|
const srcDoc = buildPluginSettingsFrameSrcDoc({
|
|
pluginId: "com.acme.settings",
|
|
trustedHtml,
|
|
channelId: TEST_CHANNEL_ID,
|
|
scriptNonce: TEST_SCRIPT_NONCE,
|
|
});
|
|
|
|
expect(srcDoc).toContain(`script-src 'nonce-${TEST_SCRIPT_NONCE}'`);
|
|
expect(srcDoc).toContain("script-src-attr 'none'");
|
|
expect(srcDoc).not.toContain("script-src 'unsafe-inline'");
|
|
expect(srcDoc).toContain(`<script nonce="${TEST_SCRIPT_NONCE}">window.ready = true;</script>`);
|
|
expect(srcDoc).toContain(`<script type="module" nonce="${TEST_SCRIPT_NONCE}">window.moduleReady = true;</script>`);
|
|
expect(srcDoc).toContain('<script src="https://evil.example/settings.js"></script>');
|
|
expect(srcDoc).toContain('onclick="window.clicked = true"');
|
|
});
|
|
|
|
it("exposes script nonce rewriting as a focused helper", () => {
|
|
expect(addPluginSettingsFrameScriptNonce("<script defer>init()</script>", TEST_SCRIPT_NONCE)).toBe(
|
|
`<script nonce="${TEST_SCRIPT_NONCE}" defer>init()</script>`
|
|
);
|
|
expect(addPluginSettingsFrameScriptNonce('<script nonce="old">init()</script>', TEST_SCRIPT_NONCE)).toBe(
|
|
`<script nonce="${TEST_SCRIPT_NONCE}">init()</script>`
|
|
);
|
|
expect(addPluginSettingsFrameScriptNonce('<script src="/settings.js"></script>', TEST_SCRIPT_NONCE)).toBe('<script src="/settings.js"></script>');
|
|
});
|
|
|
|
it("does not allow remote image beacons in the iframe CSP", () => {
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("img-src data:");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("script-src 'nonce-");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("script-src-attr 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).not.toContain("script-src 'unsafe-inline'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).not.toContain("img-src data: https:");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("connect-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("frame-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("child-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("worker-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("media-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("object-src 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).toContain("navigate-to 'none'");
|
|
expect(PLUGIN_SETTINGS_FRAME_CSP).not.toContain("frame-ancestors");
|
|
});
|
|
|
|
it("rejects script nonces that could mutate the iframe CSP", () => {
|
|
expect(buildPluginSettingsFrameCsp("safe-nonce_123+/=")).toContain("script-src 'nonce-safe-nonce_123+/='");
|
|
expect(() => buildPluginSettingsFrameCsp("nonce'; script-src 'unsafe-inline'")).toThrow("unsafe CSP characters");
|
|
expect(() => buildPluginSettingsFrameCsp("nonce with spaces")).toThrow("unsafe CSP characters");
|
|
});
|
|
|
|
it("denies sensitive browser capabilities through the iframe allow policy", () => {
|
|
const deniedCapabilities = [
|
|
"accelerometer",
|
|
"ambient-light-sensor",
|
|
"attribution-reporting",
|
|
"autoplay",
|
|
"bluetooth",
|
|
"browsing-topics",
|
|
"camera",
|
|
"clipboard-read",
|
|
"clipboard-write",
|
|
"display-capture",
|
|
"encrypted-media",
|
|
"fullscreen",
|
|
"gamepad",
|
|
"geolocation",
|
|
"gyroscope",
|
|
"hid",
|
|
"identity-credentials-get",
|
|
"idle-detection",
|
|
"local-fonts",
|
|
"magnetometer",
|
|
"microphone",
|
|
"midi",
|
|
"otp-credentials",
|
|
"payment",
|
|
"picture-in-picture",
|
|
"publickey-credentials-get",
|
|
"screen-wake-lock",
|
|
"serial",
|
|
"speaker-selection",
|
|
"storage-access",
|
|
"sync-xhr",
|
|
"usb",
|
|
"web-share",
|
|
"xr-spatial-tracking",
|
|
];
|
|
|
|
for (const capability of deniedCapabilities) {
|
|
expect(PLUGIN_SETTINGS_FRAME_ALLOW).toContain(`${capability} 'none'`);
|
|
}
|
|
expect(PLUGIN_SETTINGS_FRAME_ALLOW).not.toContain("'self'");
|
|
expect(PLUGIN_SETTINGS_FRAME_ALLOW).not.toContain("*");
|
|
});
|
|
|
|
it("remounts the iframe if plugin content attempts to navigate itself", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const onInvalidMessage = vi.fn();
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml={'<main><a href="https://evil.example">Leave</a></main>'}
|
|
onInvalidMessage={onInvalidMessage}
|
|
/>
|
|
);
|
|
|
|
const initialFrame = screen.getByTitle("Acme settings");
|
|
// Initial (legitimate) load of the srcdoc document.
|
|
fireEvent.load(initialFrame);
|
|
// A genuine self-navigation happens later than the brief duplicate-load
|
|
// coalescing window, after user/script interaction.
|
|
act(() => {
|
|
vi.advanceTimersByTime(PLUGIN_SETTINGS_FRAME_LOAD_COALESCE_MS + 1);
|
|
});
|
|
fireEvent.load(initialFrame);
|
|
|
|
await vi.waitFor(() => expect(onInvalidMessage).toHaveBeenCalledWith("unexpected_navigation"));
|
|
await vi.waitFor(() => expect(screen.getByTitle("Acme settings")).not.toBe(initialFrame));
|
|
const remountedFrame = screen.getByTitle("Acme settings");
|
|
expect(remountedFrame).toHaveAttribute("srcdoc", expect.stringContaining("navigate-to 'none'"));
|
|
expect(remountedFrame).toHaveAttribute("sandbox", PLUGIN_SETTINGS_FRAME_SANDBOX);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("ignores a spurious duplicate load of the same srcdoc without rotating the channel", () => {
|
|
const onInvalidMessage = vi.fn();
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml="<main><h1>Settings</h1></main>"
|
|
onInvalidMessage={onInvalidMessage}
|
|
/>
|
|
);
|
|
|
|
const initialFrame = screen.getByTitle("Acme settings");
|
|
const channelBefore = getFrameChannelId(initialFrame);
|
|
// The browser re-fires `load` for the same document back-to-back; this must
|
|
// not be treated as a navigation, otherwise the live channelId is silently
|
|
// rotated out from under an in-flight settings write.
|
|
fireEvent.load(initialFrame);
|
|
fireEvent.load(initialFrame);
|
|
|
|
expect(onInvalidMessage).not.toHaveBeenCalled();
|
|
const frameAfter = screen.getByTitle("Acme settings");
|
|
expect(frameAfter).toBe(initialFrame);
|
|
expect(getFrameChannelId(frameAfter)).toBe(channelBefore);
|
|
});
|
|
|
|
it("accepts versioned ready/get/set messages from the iframe only", async () => {
|
|
const onReady = vi.fn();
|
|
const onSetSetting = vi.fn();
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml="<main></main>"
|
|
settings={{ theme: "dark" }}
|
|
onReady={onReady}
|
|
onSetSetting={onSetSetting}
|
|
/>
|
|
);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId });
|
|
expect(onReady).toHaveBeenCalledWith({ type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId });
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId, requestId: "get-1", key: "theme" });
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: "plugin.settings.result", version: 1, pluginId: "com.acme.settings", channelId, requestId: "get-1", ok: true, key: "theme", value: "dark" },
|
|
"*"
|
|
)
|
|
);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "set-1",
|
|
key: "theme",
|
|
value: "light",
|
|
});
|
|
await waitFor(() => expect(onSetSetting).toHaveBeenCalledWith("theme", "light"));
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: "plugin.settings.result", version: 1, pluginId: "com.acme.settings", channelId, requestId: "set-1", ok: true, key: "theme", value: "light" },
|
|
"*"
|
|
);
|
|
});
|
|
|
|
it("rejects same-frame non-opaque origins, protocol messages from unrelated windows, and invalid payloads", () => {
|
|
const onInvalidMessage = vi.fn();
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" onInvalidMessage={onInvalidMessage} />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId }, "https://evil.example");
|
|
act(() => {
|
|
window.dispatchEvent(
|
|
new MessageEvent("message", {
|
|
data: { type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId },
|
|
origin: "null",
|
|
source: window,
|
|
})
|
|
);
|
|
});
|
|
expect(postMessage).not.toHaveBeenCalled();
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "bad",
|
|
key: "theme",
|
|
value: Number.NaN,
|
|
});
|
|
|
|
expect(onInvalidMessage).toHaveBeenNthCalledWith(1, "wrong_origin");
|
|
expect(onInvalidMessage).toHaveBeenNthCalledWith(2, "wrong_source");
|
|
expect(onInvalidMessage).toHaveBeenNthCalledWith(3, "invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "bad",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
});
|
|
|
|
it("rejects same-frame messages that do not carry the current channel id", () => {
|
|
const onInvalidMessage = vi.fn();
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" onInvalidMessage={onInvalidMessage} />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: "stale-channel",
|
|
requestId: "stale-1",
|
|
key: "theme",
|
|
});
|
|
|
|
expect(onInvalidMessage).toHaveBeenCalledWith("invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "stale-1",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
});
|
|
|
|
it("rejects same-frame messages that do not carry the current plugin id", () => {
|
|
const onInvalidMessage = vi.fn();
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" onInvalidMessage={onInvalidMessage} />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.evil.settings",
|
|
channelId,
|
|
requestId: "wrong-plugin",
|
|
key: "theme",
|
|
value: "light",
|
|
});
|
|
|
|
expect(onInvalidMessage).toHaveBeenCalledWith("invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "wrong-plugin",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
});
|
|
|
|
it("rejects same-frame messages with unknown schema fields before invoking settings handlers", () => {
|
|
const onInvalidMessage = vi.fn();
|
|
const onSetSetting = vi.fn();
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml="<main></main>"
|
|
onInvalidMessage={onInvalidMessage}
|
|
onSetSetting={onSetSetting}
|
|
/>
|
|
);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "extra-field",
|
|
key: "theme",
|
|
value: "light",
|
|
settings: { theme: "light" },
|
|
});
|
|
|
|
expect(onSetSetting).not.toHaveBeenCalled();
|
|
expect(onInvalidMessage).toHaveBeenCalledWith("invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "extra-field",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
});
|
|
|
|
it("fails settings updates when persistence is not wired", async () => {
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "set-1",
|
|
key: "theme",
|
|
value: "light",
|
|
});
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "set-1",
|
|
ok: false,
|
|
error: "settings_update_failed",
|
|
message: "Plugin settings could not be saved.",
|
|
},
|
|
"*"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("reports settings get handler failures back to the iframe", async () => {
|
|
const onGetSetting = vi.fn(async () => {
|
|
throw new Error("settings store unavailable");
|
|
});
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" onGetSetting={onGetSetting} />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId, requestId: "get-1", key: "theme" });
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "get-1",
|
|
ok: false,
|
|
error: "settings_unavailable",
|
|
message: "This setting is unavailable.",
|
|
},
|
|
"*"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("rejects unsafe values returned by custom get handlers before posting to the iframe", async () => {
|
|
const onGetSetting = vi.fn(async () => ({ headers: { authorization: "Bearer must-not-cross-frame" } }));
|
|
render(<PluginSettingsFrame pluginId="com.acme.settings" title="Acme settings" trustedHtml="<main></main>" onGetSetting={onGetSetting} />);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId, requestId: "get-secret", key: "theme" });
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "get-secret",
|
|
ok: false,
|
|
error: "settings_unavailable",
|
|
message: "This setting is unavailable.",
|
|
},
|
|
"*"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate in-flight request ids before invoking settings handlers again", async () => {
|
|
const onInvalidMessage = vi.fn();
|
|
let resolveGet: (value: string) => void = () => undefined;
|
|
const onGetSetting = vi.fn(
|
|
() =>
|
|
new Promise<string>((resolve) => {
|
|
resolveGet = resolve;
|
|
})
|
|
);
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml="<main></main>"
|
|
onGetSetting={onGetSetting}
|
|
onInvalidMessage={onInvalidMessage}
|
|
/>
|
|
);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId, requestId: "dup-1", key: "theme" });
|
|
await waitFor(() => expect(onGetSetting).toHaveBeenCalledTimes(1));
|
|
|
|
dispatchFrameMessage(iframe, { type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId, requestId: "dup-1", key: "theme" });
|
|
|
|
expect(onGetSetting).toHaveBeenCalledTimes(1);
|
|
expect(onInvalidMessage).toHaveBeenCalledWith("invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "dup-1",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
|
|
act(() => resolveGet("dark"));
|
|
await waitFor(() =>
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{ type: "plugin.settings.result", version: 1, pluginId: "com.acme.settings", channelId, requestId: "dup-1", ok: true, key: "theme", value: "dark" },
|
|
"*"
|
|
)
|
|
);
|
|
});
|
|
|
|
it("caps the number of simultaneous settings requests from the frame", async () => {
|
|
const onInvalidMessage = vi.fn();
|
|
const onGetSetting = vi.fn(() => new Promise<string>(() => undefined));
|
|
render(
|
|
<PluginSettingsFrame
|
|
pluginId="com.acme.settings"
|
|
title="Acme settings"
|
|
trustedHtml="<main></main>"
|
|
onGetSetting={onGetSetting}
|
|
onInvalidMessage={onInvalidMessage}
|
|
/>
|
|
);
|
|
|
|
const iframe = screen.getByTitle("Acme settings") as HTMLIFrameElement;
|
|
const channelId = getFrameChannelId(iframe);
|
|
const source = iframe.contentWindow;
|
|
if (!source) throw new Error("iframe contentWindow missing");
|
|
const postMessage = vi.spyOn(source, "postMessage").mockImplementation(() => undefined);
|
|
|
|
for (let index = 0; index < PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS; index += 1) {
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: `req-${index}`,
|
|
key: "theme",
|
|
});
|
|
}
|
|
await waitFor(() => expect(onGetSetting).toHaveBeenCalledTimes(PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS));
|
|
|
|
dispatchFrameMessage(iframe, {
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "overflow",
|
|
key: "theme",
|
|
value: "light",
|
|
});
|
|
|
|
expect(onGetSetting).toHaveBeenCalledTimes(PLUGIN_SETTINGS_FRAME_MAX_IN_FLIGHT_REQUESTS);
|
|
expect(onInvalidMessage).toHaveBeenCalledWith("invalid_payload");
|
|
expect(postMessage).toHaveBeenCalledWith(
|
|
{
|
|
type: "plugin.settings.result",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId,
|
|
requestId: "overflow",
|
|
ok: false,
|
|
error: "invalid_payload",
|
|
message: "The plugin settings request is invalid.",
|
|
},
|
|
"*"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parsePluginSettingsFrameMessage", () => {
|
|
it("requires protocol version and known payload shapes", () => {
|
|
expect(parsePluginSettingsFrameMessage({ type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId: TEST_CHANNEL_ID })).toEqual({
|
|
type: "plugin.settings.ready",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
});
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "theme",
|
|
})
|
|
).toEqual({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "theme",
|
|
});
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r2",
|
|
key: "theme",
|
|
value: { accent: "blue" },
|
|
})
|
|
).toEqual({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r2",
|
|
key: "theme",
|
|
value: { accent: "blue" },
|
|
});
|
|
expect(parsePluginSettingsFrameMessage({ type: "plugin.settings.ready", version: 2, channelId: TEST_CHANNEL_ID })).toBeNull();
|
|
expect(parsePluginSettingsFrameMessage({ type: "plugin.settings.ready", version: 1 })).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r3",
|
|
key: "theme",
|
|
value: () => "nope",
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it("bounds request identifiers and setting keys", () => {
|
|
expect(
|
|
parsePluginSettingsFrameMessage({ type: "plugin.settings.ready", version: 1, pluginId: "com.acme.settings", channelId: TEST_CHANNEL_ID, requestId: "" })
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.ready",
|
|
version: 1,
|
|
pluginId: "x".repeat(PLUGIN_SETTINGS_FRAME_MAX_PLUGIN_ID_LENGTH + 1),
|
|
channelId: TEST_CHANNEL_ID,
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "x".repeat(PLUGIN_SETTINGS_FRAME_MAX_REQUEST_ID_LENGTH + 1),
|
|
key: "theme",
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "k".repeat(PLUGIN_SETTINGS_FRAME_MAX_KEY_LENGTH + 1),
|
|
value: true,
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects protocol identifiers with unsafe characters", () => {
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings<script>",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "theme",
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: "channel\ninjected",
|
|
requestId: "r1",
|
|
key: "theme",
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "req/../../secret",
|
|
key: "theme",
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects messages bound to a different plugin identity", () => {
|
|
expect(
|
|
parsePluginSettingsFrameMessage(
|
|
{ type: "plugin.settings.get", version: 1, pluginId: "com.evil.settings", channelId: TEST_CHANNEL_ID, requestId: "r1", key: "theme" },
|
|
"com.acme.settings"
|
|
)
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage(
|
|
{ type: "plugin.settings.get", version: 1, pluginId: "com.acme.settings", channelId: TEST_CHANNEL_ID, requestId: "r1", key: "theme" },
|
|
"com.acme.settings"
|
|
)
|
|
).toEqual({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "theme",
|
|
});
|
|
});
|
|
|
|
it("rejects messages with extra protocol fields", () => {
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.ready",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
value: "unexpected",
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "theme",
|
|
value: "unexpected",
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r2",
|
|
key: "theme",
|
|
value: "dark",
|
|
ok: true,
|
|
})
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects setting keys the persistence layer would refuse", () => {
|
|
const invalidKeys = [
|
|
"../theme",
|
|
"__proto__",
|
|
"constructor",
|
|
"prototype",
|
|
"apiKey",
|
|
"access-token",
|
|
"password",
|
|
"secret.value",
|
|
"authorization",
|
|
"credentials",
|
|
"cookie",
|
|
"sessionId",
|
|
"clientSecret",
|
|
"webhookUrl",
|
|
];
|
|
|
|
for (const key of invalidKeys) {
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.get",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: `get-${key}`,
|
|
key,
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: `set-${key}`,
|
|
key,
|
|
value: "value",
|
|
})
|
|
).toBeNull();
|
|
}
|
|
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "set-valid",
|
|
key: "theme.accent:color-1",
|
|
value: "blue",
|
|
})
|
|
).toEqual({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "set-valid",
|
|
key: "theme.accent:color-1",
|
|
value: "blue",
|
|
});
|
|
});
|
|
|
|
it("bounds set-message value size and shape before persistence handlers run", () => {
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r1",
|
|
key: "blob",
|
|
value: "x".repeat(PLUGIN_SETTINGS_FRAME_MAX_VALUE_BYTES + 1),
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r2",
|
|
key: "items",
|
|
value: Array.from({ length: PLUGIN_SETTINGS_FRAME_MAX_ARRAY_LENGTH + 1 }, () => true),
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r3",
|
|
key: "object",
|
|
value: Object.fromEntries(Array.from({ length: PLUGIN_SETTINGS_FRAME_MAX_OBJECT_KEYS + 1 }, (_, index) => [`key${index}`, true])),
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r4",
|
|
key: "theme",
|
|
value: JSON.parse('{"accent":"blue","__proto__":{"polluted":true}}'),
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r5",
|
|
key: "theme",
|
|
value: { appearance: { apiKey: "must-not-reach-persistence" } },
|
|
})
|
|
).toBeNull();
|
|
expect(
|
|
parsePluginSettingsFrameMessage({
|
|
type: "plugin.settings.set",
|
|
version: 1,
|
|
pluginId: "com.acme.settings",
|
|
channelId: TEST_CHANNEL_ID,
|
|
requestId: "r6",
|
|
key: "theme",
|
|
value: { headers: { authorization: "Bearer must-not-reach-persistence" } },
|
|
})
|
|
).toBeNull();
|
|
});
|
|
});
|