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:
1 parent
d4400e49ad
commit
6d6994f009
6 files changed
+232
-20
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user