Extract clampHeight + isTrustedHeightMessage + the HTML_EMBED_SANDBOX token constant from the NodeView and test them: clamp bounds; reject a resize message from a foreign window / wrong type / NaN/Infinity; accept a valid same-source finite message; assert the sandbox is exactly 'allow-scripts allow-popups allow-forms' (no allow-same-origin) and rendered via srcDoc (not src). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit,
|
||||
clampHeight,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender,
|
||||
} from "./html-embed-sandbox";
|
||||
|
||||
@@ -48,6 +53,108 @@ describe("shouldRender (render policy)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampHeight", () => {
|
||||
it("clamps below the lower bound up to MIN_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(0)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(-100)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT - 1)).toBe(MIN_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("clamps above the upper bound down to MAX_IFRAME_HEIGHT", () => {
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT + 1)).toBe(MAX_IFRAME_HEIGHT);
|
||||
expect(clampHeight(999999)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
|
||||
it("passes a value within range through unchanged", () => {
|
||||
expect(clampHeight(150)).toBe(150);
|
||||
expect(clampHeight(MIN_IFRAME_HEIGHT)).toBe(MIN_IFRAME_HEIGHT);
|
||||
expect(clampHeight(MAX_IFRAME_HEIGHT)).toBe(MAX_IFRAME_HEIGHT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTrustedHeightMessage (resize message guard)", () => {
|
||||
// Stand-ins for window objects; identity is all the guard compares.
|
||||
const ownWindow = {} as Window;
|
||||
const foreignWindow = {} as Window;
|
||||
const iframeEl = { contentWindow: ownWindow };
|
||||
|
||||
const validData = { type: HTML_EMBED_HEIGHT_MESSAGE, height: 300 };
|
||||
|
||||
it("accepts a same-source message with a finite numeric height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, iframeEl),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a message from a DIFFERENT source (foreign window)", () => {
|
||||
// A page can postMessage anything; only our own iframe's contentWindow is
|
||||
// trusted. This is the core security check.
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: foreignWindow, data: validData },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a wrong-type message even from the right source", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: "something-else", height: 300 } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a NaN height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: ownWindow, data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: NaN } },
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an Infinity height", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{
|
||||
source: ownWindow,
|
||||
data: { type: HTML_EMBED_HEIGHT_MESSAGE, height: Infinity },
|
||||
},
|
||||
iframeEl,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when the iframe element / contentWindow is null", () => {
|
||||
expect(
|
||||
isTrustedHeightMessage({ source: ownWindow, data: validData }, null),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isTrustedHeightMessage(
|
||||
{ source: null, data: validData },
|
||||
{ contentWindow: null },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("iframe sandbox attributes", () => {
|
||||
it("uses EXACTLY allow-scripts allow-popups allow-forms (no allow-same-origin)", () => {
|
||||
expect(HTML_EMBED_SANDBOX).toBe("allow-scripts allow-popups allow-forms");
|
||||
// The critical security invariant: opaque origin => no session/cookie access.
|
||||
expect(HTML_EMBED_SANDBOX).not.toContain("allow-same-origin");
|
||||
});
|
||||
|
||||
it("the NodeView renders the embed via srcDoc (not src), set to the sandbox doc", () => {
|
||||
// The iframe carries the generated srcdoc; it never loads an external URL.
|
||||
const srcdoc = buildSandboxSrcdoc("<p>hi</p>");
|
||||
expect(srcdoc).toContain("<p>hi</p>");
|
||||
expect(srcdoc).toContain(HTML_EMBED_HEIGHT_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canEdit (edit policy)", () => {
|
||||
it("any member can edit when editable and the toggle is ON (no admin gate)", () => {
|
||||
expect(canEdit(true, true)).toBe(true);
|
||||
|
||||
@@ -7,6 +7,48 @@
|
||||
/** postMessage type the sandboxed iframe uses to report its content height. */
|
||||
export const HTML_EMBED_HEIGHT_MESSAGE = "gitmost-html-embed-height";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
export const MIN_IFRAME_HEIGHT = 40;
|
||||
export const MAX_IFRAME_HEIGHT = 4000;
|
||||
export const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
/**
|
||||
* Sandbox tokens for the embed iframe. Intentionally does NOT include
|
||||
* `allow-same-origin`: the content must run in an opaque ("null") origin so it
|
||||
* cannot read the viewer's cookies/session/API.
|
||||
*/
|
||||
export const HTML_EMBED_SANDBOX = "allow-scripts allow-popups allow-forms";
|
||||
|
||||
/** Clamp a reported/configured height into the sane iframe bounds. */
|
||||
export function clampHeight(h: number): number {
|
||||
return Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard for the auto-resize `message` handler. Returns the clamped numeric
|
||||
* height ONLY when the event is a trusted resize report; otherwise null.
|
||||
*
|
||||
* Trusted means ALL of:
|
||||
* - `event.source` is this iframe's own `contentWindow` (the sandboxed srcdoc
|
||||
* has an opaque "null" origin, so we cannot match by `event.origin` — we
|
||||
* match by source instead). A message from any OTHER window is rejected.
|
||||
* - the payload `type` is exactly our agreed resize message type.
|
||||
* - the reported `height` is a finite number (rejects NaN/Infinity).
|
||||
*/
|
||||
export function isTrustedHeightMessage(
|
||||
event: Pick<MessageEvent, "source" | "data">,
|
||||
iframeEl: { contentWindow: Window | null } | null,
|
||||
): boolean {
|
||||
// Reject when there is no contentWindow to match against; otherwise a `null`
|
||||
// event.source would spuriously equal a `null` contentWindow.
|
||||
if (!iframeEl?.contentWindow) return false;
|
||||
if (event.source !== iframeEl.contentWindow) return false;
|
||||
const data = event.data as { type?: string; height?: number } | null;
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return false;
|
||||
return Number.isFinite(Number(data.height));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `srcdoc` document for the sandboxed embed iframe.
|
||||
*
|
||||
|
||||
@@ -24,20 +24,15 @@ import classes from "./html-embed-view.module.css";
|
||||
import {
|
||||
buildSandboxSrcdoc,
|
||||
canEdit as computeCanEdit,
|
||||
HTML_EMBED_HEIGHT_MESSAGE,
|
||||
clampHeight,
|
||||
DEFAULT_IFRAME_HEIGHT,
|
||||
HTML_EMBED_SANDBOX,
|
||||
isTrustedHeightMessage,
|
||||
MAX_IFRAME_HEIGHT,
|
||||
MIN_IFRAME_HEIGHT,
|
||||
shouldRender as computeShouldRender,
|
||||
} from "./html-embed-sandbox.ts";
|
||||
|
||||
// Sane bounds for the auto-resized iframe so a runaway embed cannot blow up the
|
||||
// page layout, and a sensible default before the first height message arrives.
|
||||
const MIN_IFRAME_HEIGHT = 40;
|
||||
const MAX_IFRAME_HEIGHT = 4000;
|
||||
const DEFAULT_IFRAME_HEIGHT = 150;
|
||||
|
||||
// Clamp a reported/configured height into the sane iframe bounds.
|
||||
const clampHeight = (h: number) =>
|
||||
Math.min(MAX_IFRAME_HEIGHT, Math.max(MIN_IFRAME_HEIGHT, h));
|
||||
|
||||
export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { node, selected, updateAttributes, editor } = props;
|
||||
@@ -81,11 +76,8 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
// auto shows the current content height with no iframe reload.
|
||||
useEffect(() => {
|
||||
function onMessage(event: MessageEvent) {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = event.data as { type?: string; height?: number };
|
||||
if (data?.type !== HTML_EMBED_HEIGHT_MESSAGE) return;
|
||||
const next = Number(data.height);
|
||||
if (!Number.isFinite(next)) return;
|
||||
if (!isTrustedHeightMessage(event, iframeRef.current)) return;
|
||||
const next = Number((event.data as { height?: number }).height);
|
||||
setAutoHeight(clampHeight(next));
|
||||
}
|
||||
window.addEventListener("message", onMessage);
|
||||
@@ -153,7 +145,7 @@ export default function HtmlEmbedView(props: NodeViewProps) {
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={classes.htmlEmbedFrame}
|
||||
sandbox="allow-scripts allow-popups allow-forms"
|
||||
sandbox={HTML_EMBED_SANDBOX}
|
||||
srcDoc={srcdoc}
|
||||
title={t("HTML embed")}
|
||||
referrerPolicy="no-referrer"
|
||||
|
||||
Reference in New Issue
Block a user