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 <script> ran with the
viewer's panel session and CSRF token (root included). Both now open an empty
shell tab inside the click and render the page in an <iframe srcdoc> with
sandbox="allow-scripts allow-popups allow-forms" (no allow-same-origin):
an opaque origin, no opener. Tests assert the sandbox attribute and scan the
source for any remaining text/html blob / document.write / blob tab path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:18:11 -07:00
1 parent d4400e49ad
commit 6d6994f009
6 files changed
+232 -20

No files matched your search

+32 -8
View File
@@ -167,22 +167,46 @@ describe('HistoryModal', () => {
expect(live.guard.isDirty()).toBe(false);
});
test('Preview of a multi-page snapshot asks which page, then opens a blob tab', async () => {
test('Preview of a multi-page snapshot asks which page, then opens a sandboxed srcdoc tab (no blob, no same-origin)', async () => {
mockApi();
const fakeWin = { closed: false, close: vi.fn(), location: { href: '' } };
const openSpy = vi.fn(() => fakeWin);
const firstWin = { closed: false, close: vi.fn(), location: { href: '' } };
const shell = document.implementation.createHTMLDocument('');
const previewWin = { closed: false, close: vi.fn(), opener: window, document: shell };
const openSpy = vi.fn().mockReturnValueOnce(firstWin).mockReturnValueOnce(previewWin);
vi.stubGlobal('open', openSpy);
const createUrl = vi.fn(() => 'blob:preview');
(URL as any).createObjectURL = createUrl;
mount(cfg, <HistoryModal open onClose={() => {}} />);
await flush();
await click(btn('Preview', row(0)));
expect(fakeWin.close).toHaveBeenCalled();
expect(firstWin.close).toHaveBeenCalled();
await click(btn('About', document.body.querySelector('.wsb-history-pages')!));
expect(createUrl).toHaveBeenCalledTimes(1);
const blob = (createUrl.mock.calls[0] as any)[0] as Blob;
expect(await blob.text()).toContain('Snapshot about');
expect(openSpy).toHaveBeenLastCalledWith('blob:preview', '_blank');
expect(createUrl).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenLastCalledWith('', '_blank');
expect(firstWin.location.href).toBe('');
const frame = shell.querySelector('iframe')!;
expect(frame.getAttribute('sandbox')).toBe('allow-scripts allow-popups allow-forms');
expect(frame.srcdoc).toContain('Snapshot about');
expect(shell.querySelector('script')).toBeNull();
expect(previewWin.opener).toBeNull();
});
test('single-page Preview fills the tab opened in the click with a sandboxed frame carrying the head code', async () => {
mockApi({ snap: { pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: state('Only page') }], head_code: '<script>opener.WHP_CONFIG</script>' } });
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, <HistoryModal open onClose={() => {}} />);
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();
});
});
+4 -2
View File
@@ -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();
+48 -5
View File
@@ -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<string, unknown>) => {
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<HTMLButtonElement>('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('<title>Home</title>');
} 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 = '<script>window.__pwned = opener && opener.WHP_CONFIG</script>';
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<HTMLButtonElement>('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();
}
});
});
+11 -5
View File
@@ -264,9 +264,11 @@ export const TopBar: React.FC<TopBarProps> = ({ 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<TopBarProps> = ({ 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]);
+83
View File
@@ -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 = '<!DOCTYPE html><html><head><script>steal()</script></head><body><p>Hi</p></body></html>';
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('<p>x</p>', { win })).toBe(win);
expect(open).not.toHaveBeenCalled();
expect(win.document.querySelector('iframe')!.getAttribute('sandbox')).toBe(PREVIEW_SANDBOX);
expect(openSandboxedPreview('<p>x</p>')).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([]);
});
});
+54
View File
@@ -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 `<iframe srcdoc>`
* whose sandbox allows scripts (animations, embeds, head-code widgets) but
* NOT `allow-same-origin`, so it gets an opaque origin: no panel cookies,
* storage, DOM or opener access. Popups it opens inherit the sandbox
* (no `allow-popups-to-escape-sandbox`).
*/
export const PREVIEW_SANDBOX = 'allow-scripts allow-popups allow-forms';
export interface SandboxedPreviewOptions {
/** Tab title shown by the shell. */
title?: string;
/** A tab already opened inside the user's click (popup blockers); reused when still open. */
win?: Window | null;
}
/** Open (or reuse) a tab and render `html` in it, sandboxed. Returns the tab, or null when blocked. */
export function openSandboxedPreview(html: string, opts: SandboxedPreviewOptions = {}): Window | null {
const win = opts.win && !opts.win.closed ? opts.win : window.open('', '_blank');
if (!win) return null;
const doc = win.document;
doc.title = opts.title || 'Preview';
const head = doc.head || doc.documentElement.appendChild(doc.createElement('head'));
const viewport = doc.createElement('meta');
viewport.setAttribute('name', 'viewport');
viewport.setAttribute('content', 'width=device-width, initial-scale=1');
head.appendChild(viewport);
const style = doc.createElement('style');
style.textContent = 'html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#fff}'
+ 'iframe{display:block;border:0;width:100%;height:100%}';
head.appendChild(style);
const body = doc.body || doc.documentElement.appendChild(doc.createElement('body'));
while (body.firstChild) body.removeChild(body.firstChild);
const frame = doc.createElement('iframe');
frame.setAttribute('sandbox', PREVIEW_SANDBOX);
frame.setAttribute('title', opts.title || 'Preview');
// srcdoc is an attribute value, never parsed into the shell document.
frame.srcdoc = html;
body.appendChild(frame);
// The shell has no use for a handle back to the editor.
try { (win as { opener: unknown }).opener = null; } catch { /* some browsers make it read-only */ }
return win;
}