From 93c28cf4e6d856e06fff08d2d7577a00ae21c9ae Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 27 Mar 2026 14:27:55 +0400 Subject: [PATCH] fix(widgets): fix CSP violations in pro widget iframe (#2362) * fix(widgets): fix CSP violations in pro widget iframe by using sandbox page srcdoc iframes inherit the parent page's Content-Security-Policy response headers. The parent's hash-based script-src blocks inline scripts and cdn.jsdelivr.net (Chart.js), making pro widgets silently broken. Fix: replace srcdoc with a dedicated /wm-widget-sandbox.html page that has its own permissive CSP via vercel.json route headers. Widget HTML is passed via postMessage after the sandbox page loads. - Add public/wm-widget-sandbox.html: minimal relay page that receives HTML via postMessage and renders it with document.open/write/close. Validates message origin against known worldmonitor.app domains. - vercel.json: add CSP override route for sandbox page (unsafe-inline + cdn.jsdelivr.net), exclude from SPA rewrite and no-cache rules. - widget-sanitizer.ts: switch wrapProWidgetHtml to src + data-wm-id, store widget bodies in module-level Map, auto-mount via MutationObserver. Fix race condition (always use load event, not readyState check). Delete store entries after mount to prevent memory leak. - tests: update 4 tests to reflect new postMessage architecture. * test(deploy): update deploy-config test for wm-widget-sandbox.html exclusion --- public/wm-widget-sandbox.html | 21 +++++++++++++ src/utils/widget-sanitizer.ts | 57 +++++++++++++++++++++++++++++------ tests/deploy-config.test.mjs | 2 +- tests/widget-builder.test.mjs | 44 +++++++++++++-------------- vercel.json | 11 +++++-- 5 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 public/wm-widget-sandbox.html diff --git a/public/wm-widget-sandbox.html b/public/wm-widget-sandbox.html new file mode 100644 index 000000000..6e9fad377 --- /dev/null +++ b/public/wm-widget-sandbox.html @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/utils/widget-sanitizer.ts b/src/utils/widget-sanitizer.ts index b0433f6f3..268806b22 100644 --- a/src/utils/widget-sanitizer.ts +++ b/src/utils/widget-sanitizer.ts @@ -41,14 +41,10 @@ export function wrapWidgetHtml(html: string, extraClass = ''): string { `; } -function escapeSrcdoc(str: string): string { - return str - .replace(/&/g, '&') - .replace(/"/g, '"'); -} +const widgetBodyStore = new Map(); -export function wrapProWidgetHtml(bodyContent: string): string { - const doc = ` +function buildWidgetDoc(bodyContent: string): string { + return ` @@ -67,6 +63,49 @@ td{padding:5px 8px;border-bottom:1px solid var(--border-subtle);color:var(--text ${bodyContent} `; - - return `
`; +} + +function mountProWidget(iframe: HTMLIFrameElement): void { + const id = iframe.dataset.wmId; + if (!id) return; + const body = widgetBodyStore.get(id); + if (!body) return; + // Delete immediately — new ID is generated on every render, so no re-use + widgetBodyStore.delete(id); + const html = buildWidgetDoc(body); + // Always use load event: when MutationObserver fires, iframe.contentDocument is + // still about:blank (readyState 'complete'), not the sandbox page. Sending here + // would target the wrong window. Wait for the real load instead. + iframe.addEventListener('load', () => { + iframe.contentWindow?.postMessage({ type: 'wm-html', html }, '*'); + }, { once: true }); +} + +if (typeof document !== 'undefined') { + const observer = new MutationObserver((mutations) => { + for (const mut of mutations) { + for (const node of mut.addedNodes) { + if (!(node instanceof Element)) continue; + if (node instanceof HTMLIFrameElement && node.dataset.wmId) { + mountProWidget(node); + } else { + node.querySelectorAll('iframe[data-wm-id]').forEach(mountProWidget); + } + } + } + }); + const startObserving = (): void => { + if (document.body) observer.observe(document.body, { childList: true, subtree: true }); + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startObserving); + } else { + startObserving(); + } +} + +export function wrapProWidgetHtml(bodyContent: string): string { + const id = `wm-${Math.random().toString(36).slice(2)}`; + widgetBodyStore.set(id, bodyContent); + return `
`; } diff --git a/tests/deploy-config.test.mjs b/tests/deploy-config.test.mjs index 8832fb347..d09e1e92b 100644 --- a/tests/deploy-config.test.mjs +++ b/tests/deploy-config.test.mjs @@ -16,7 +16,7 @@ const getCacheHeaderValue = (sourcePath) => { describe('deploy/cache configuration guardrails', () => { it('disables caching for HTML entry routes on Vercel', () => { - const spaNoCache = getCacheHeaderValue('/((?!api|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known).*)'); + const spaNoCache = getCacheHeaderValue('/((?!api|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)'); assert.equal(spaNoCache, 'no-cache, no-store, must-revalidate'); }); diff --git a/tests/widget-builder.test.mjs b/tests/widget-builder.test.mjs index 01e15a956..5888a4c6d 100644 --- a/tests/widget-builder.test.mjs +++ b/tests/widget-builder.test.mjs @@ -1096,48 +1096,48 @@ describe('PRO widget — store and sanitizer', () => { ); }); - it('wrapProWidgetHtml places CSP as first head child (client-owned skeleton)', () => { - const fnIdx = san.indexOf('wrapProWidgetHtml'); - const fnBody = san.slice(fnIdx, fnIdx + 800); + it('widget document builder places CSP as first head child (client-owned skeleton)', () => { assert.ok( - fnBody.includes('Content-Security-Policy'), - 'wrapProWidgetHtml must embed CSP in the head', + san.includes('Content-Security-Policy'), + 'widget sanitizer must embed CSP in the document head', ); // CSP meta should come before any style tag - const cspPos = fnBody.indexOf('Content-Security-Policy'); - const stylePos = fnBody.indexOf('