From 6d6994f009719129d0b24776a7720ee5e82dc0bd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 13:18:11 -0700 Subject: [PATCH] fix(preview): render customer head code only in a sandboxed srcdoc iframe Preview and history Preview opened the composed page (raw site head code included) as a panel-origin blob: tab, so a customer's ' } }); + const shell = document.implementation.createHTMLDocument(''); + const win = { closed: false, close: vi.fn(), opener: window, document: shell, location: { href: '' } }; + vi.stubGlobal('open', vi.fn(() => win)); + const createUrl = vi.fn(() => 'blob:preview'); + (URL as any).createObjectURL = createUrl; + mount(cfg, {}} />); + await flush(); + await click(btn('Preview', row(0))); + expect(createUrl).not.toHaveBeenCalled(); + expect(win.location.href).toBe(''); + const frame = shell.querySelector('iframe')!; + expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-popups allow-forms'); + expect(frame.srcdoc).toContain('opener.WHP_CONFIG'); + expect(shell.querySelector('script')).toBeNull(); }); }); diff --git a/craft/src/panels/history/HistoryModal.tsx b/craft/src/panels/history/HistoryModal.tsx index dc70cfa..1bf1bc4 100644 --- a/craft/src/panels/history/HistoryModal.tsx +++ b/craft/src/panels/history/HistoryModal.tsx @@ -10,6 +10,7 @@ import { useNotices } from '../../state/NoticeContext'; import { useRegisterCommands } from '../../editor/commands'; import { useHistoryApi, type HistoryItem } from '../../hooks/useHistoryApi'; import { repairOrphanNodes } from '../../utils/orphan-repair'; +import { openSandboxedPreview } from '../../utils/sandboxed-preview'; import { buildSnapshotPreviewHtml, planPageRestore, @@ -151,8 +152,9 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ const openPreview = useCallback((snapshot: Snapshot, pageIndex: number, win: Window | null) => { try { const html = buildSnapshotPreviewHtml(snapshot, pageIndex, window.location.origin); - const url = URL.createObjectURL(new Blob([html], { type: 'text/html' })); - if (win && !win.closed) win.location.href = url; else window.open(url, '_blank'); + // The snapshot's head code is customer-controlled: sandboxed srcdoc iframe only. + const pageName = snapshotToPages(snapshot)[pageIndex]?.name; + openSandboxedPreview(html, { title: pageName ? `Version preview - ${pageName}` : 'Version preview', win }); } catch (e) { console.error('History preview failed:', e); win?.close(); diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index b28cfa4..02db1f2 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -45,6 +45,19 @@ vi.mock('../../state/PageContext', () => ({ }), })); +// Preview reads the site design (head code) -- default design plus a settable headCode. +const designFixture: { headCode: string } = { headCode: '' }; +vi.mock('../../state/SiteDesignContext', async (orig) => { + const actual: any = await orig(); + return { + ...actual, + useSiteDesign: () => { + const real = actual.useSiteDesign(); + return { ...real, design: { ...real.design, headCode: designFixture.headCode } }; + }, + }; +}); + vi.mock('@craftjs/core', () => ({ useEditor: (collector?: (state: unknown, query: unknown) => Record) => { const query = { @@ -340,15 +353,16 @@ describe('TopBar Preview head parity (item 14)', () => { checkFixture.liveState = rootState(); checkFixture.footerState = rootState({ fontFamily: 'Merriweather, serif' }); checkFixture.pages = [{ id: 'home', name: 'Home', slug: 'index', craftState: null, seo: { metaTitle: ' ' } }]; - let blob: Blob | null = null; - const createUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((b) => { blob = b as Blob; return 'blob:x'; }); - const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + const shell = document.implementation.createHTMLDocument(''); + const fakeWin = { closed: false, close: vi.fn(), opener: window, document: shell } as unknown as Window; + const createUrl = vi.spyOn(URL, 'createObjectURL'); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => fakeWin); try { render(); const btn = container.querySelector('button[aria-label="Preview"]')!; await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - await vi.waitFor(() => expect(blob).not.toBeNull()); - const html = await blob!.text(); + await vi.waitFor(() => expect(shell.querySelector('iframe')).not.toBeNull()); + const html = shell.querySelector('iframe')!.srcdoc; expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap'); expect(html).toContain('Home'); } finally { @@ -356,4 +370,33 @@ describe('TopBar Preview head parity (item 14)', () => { openSpy.mockRestore(); } }); + + test('customer head code is rendered only in a sandboxed srcdoc iframe, never a panel-origin blob tab', async () => { + checkFixture.liveState = rootState(); + checkFixture.pages = [{ id: 'home', name: 'Home', slug: 'index', craftState: null }]; + designFixture.headCode = ''; + const shell = document.implementation.createHTMLDocument(''); + const fakeWin = { closed: false, close: vi.fn(), opener: window, document: shell } as unknown as Window; + const createUrl = vi.spyOn(URL, 'createObjectURL'); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => fakeWin); + try { + render(); + const btn = container.querySelector('button[aria-label="Preview"]')!; + await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); + await vi.waitFor(() => expect(shell.querySelector('iframe')).not.toBeNull()); + // Opened synchronously inside the click, as an empty shell -- never a blob: URL. + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy).toHaveBeenCalledWith('', '_blank'); + expect(createUrl).not.toHaveBeenCalled(); + const frame = shell.querySelector('iframe')!; + expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-popups allow-forms'); + expect(frame.srcdoc).toContain('window.__pwned'); + expect(shell.querySelector('script')).toBeNull(); + expect((fakeWin as any).opener).toBeNull(); + } finally { + designFixture.headCode = ''; + createUrl.mockRestore(); + openSpy.mockRestore(); + } + }); }); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index 4fe93a3..b8d47f3 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -264,9 +264,11 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => // Extracted so both the desktop inline Preview button and the mobile // overflow-menu "Preview" item can trigger the exact same flow. const handlePreview = useCallback(() => { + // Open the tab inside the user gesture (popup blockers), fill it once the export is ready. + const win = window.open('', '_blank'); try { const serialized = query.serialize(); - import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml, buildAnimationScript }) => { + Promise.all([import('../../utils/html-export'), import('../../utils/sandboxed-preview')]).then(([{ exportToHtml, exportBodyHtml, buildAnimationScript }, { openSandboxedPreview }]) => { // Get header HTML let headerHtml = ''; try { @@ -338,16 +340,20 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => html = html.replace(bodyMatch[1], () => composedBody); } - // Make proxy URLs absolute so they work from the blob: context + // Make proxy URLs absolute so they resolve from the sandboxed (opaque-origin) frame. const origin = window.location.origin; html = html.replace(/src="\/api\//g, `src="${origin}/api/`); html = html.replace(/url\('\/api\//g, `url('${origin}/api/`); - const blob = new Blob([html], { type: 'text/html' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); + // Customer head code is in `html`: render it ONLY in a sandboxed srcdoc + // iframe (no allow-same-origin), never as a panel-origin document. + openSandboxedPreview(html, { title, win }); + }).catch((e) => { + console.error('Preview failed:', e); + win?.close(); }); } catch (e) { console.error('Preview failed:', e); + win?.close(); } }, [query, headerPage, footerPage, whpConfig, isWHP, design, pages, activePageId]); diff --git a/craft/src/utils/sandboxed-preview.test.ts b/craft/src/utils/sandboxed-preview.test.ts new file mode 100644 index 0000000..b6a64a8 --- /dev/null +++ b/craft/src/utils/sandboxed-preview.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test, vi } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { openSandboxedPreview, PREVIEW_SANDBOX } from './sandboxed-preview'; + +/** A real (detached) document standing in for the about:blank popup. */ +function fakeWindow() { + const doc = document.implementation.createHTMLDocument(''); + return { closed: false, close: vi.fn(), opener: window as any, document: doc } as unknown as Window & { document: Document }; +} + +describe('openSandboxedPreview (customer head code never runs in the panel origin)', () => { + test('renders the page only inside an opaque-origin sandboxed srcdoc iframe', () => { + const win = fakeWindow(); + const open = vi.spyOn(window, 'open').mockImplementation(() => win); + const createUrl = vi.spyOn(URL, 'createObjectURL'); + try { + const html = '

Hi

'; + const out = openSandboxedPreview(html, { title: 'Home' }); + expect(out).toBe(win); + expect(open).toHaveBeenCalledWith('', '_blank'); + expect(createUrl).not.toHaveBeenCalled(); + const frames = win.document.querySelectorAll('iframe'); + expect(frames).toHaveLength(1); + const f = frames[0]; + expect(PREVIEW_SANDBOX).toBe('allow-scripts allow-popups allow-forms'); + expect(f.getAttribute('sandbox')).toBe('allow-scripts allow-popups allow-forms'); + expect(f.getAttribute('sandbox')).not.toContain('allow-same-origin'); + expect(f.srcdoc).toBe(html); + // The customer markup is never parsed into the shell (same-origin) document itself. + expect(win.document.querySelector('script')).toBeNull(); + expect(win.document.title).toBe('Home'); + expect(win.opener).toBeNull(); + } finally { + open.mockRestore(); + createUrl.mockRestore(); + } + }); + + test('reuses a tab opened inside the click and returns null when the popup is blocked', () => { + const win = fakeWindow(); + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + try { + expect(openSandboxedPreview('

x

', { win })).toBe(win); + expect(open).not.toHaveBeenCalled(); + expect(win.document.querySelector('iframe')!.getAttribute('sandbox')).toBe(PREVIEW_SANDBOX); + expect(openSandboxedPreview('

x

')).toBeNull(); + } finally { + open.mockRestore(); + } + }); +}); + +describe('no same-origin path for composed-page HTML remains in the source', () => { + const root = join(__dirname, '..'); + const files: string[] = []; + const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p); + else if (/\.(ts|tsx)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) && !p.includes('test-utils')) files.push(p); + } + }; + walk(root); + + test('nothing builds a text/html blob, document.writes, or opens a blob: URL', () => { + const offenders = files.filter((f) => { + const src = readFileSync(f, 'utf8'); + return /type:\s*['"]text\/html['"]/.test(src) || /document\.write\s*\(/.test(src) || /\.location\.href\s*=\s*url/.test(src); + }); + expect(offenders.map((f) => f.slice(root.length + 1))).toEqual([]); + }); + + test('no srcdoc iframe (editor-origin content) is ever also allow-same-origin', () => { + // allow-same-origin is only acceptable on third-party src= embeds (they keep THEIR origin); + // on a srcdoc frame it would hand customer markup the panel origin. + const offenders = files.filter((f) => { + const src = readFileSync(f, 'utf8'); + return /srcdoc/i.test(src) && /allow-same-origin['"\s]/.test(src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')); + }); + expect(offenders.map((f) => f.slice(root.length + 1))).toEqual([]); + }); +}); diff --git a/craft/src/utils/sandboxed-preview.ts b/craft/src/utils/sandboxed-preview.ts new file mode 100644 index 0000000..aedd40e --- /dev/null +++ b/craft/src/utils/sandboxed-preview.ts @@ -0,0 +1,54 @@ +/** + * Preview viewer for a composed page (Preview, history Preview). + * + * The composed page carries customer-controlled HTML -- above all the site's + * raw head code -- so it must never run in the panel origin: a blob: URL or a + * script-written window inherits the panel origin, and a script there runs + * with the viewer's (possibly root's) panel session and CSRF token. + * + * Instead the new tab holds only a trusted shell (built with DOM APIs, no + * customer markup), and the page is rendered inside an `