diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts index 70534be3..bf7206a3 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts +++ b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.test.ts @@ -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("
hi
"); + expect(srcdoc).toContain("hi
"); + 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); diff --git a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts index d4ea79f4..d8659331 100644 --- a/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts +++ b/apps/client/src/features/editor/components/html-embed/html-embed-sandbox.ts @@ -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