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
This commit is contained in:
Elie Habib
2026-03-27 14:27:55 +04:00
committed by GitHub
parent a90efb2f06
commit 93c28cf4e6
5 changed files with 101 additions and 34 deletions

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<script>
(function () {
var handled = false;
window.addEventListener('message', function (e) {
if (handled) return;
if (!e.data || e.data.type !== 'wm-html') return;
var origin = e.origin || '';
if (!origin.endsWith('worldmonitor.app') && !/^https?:\/\/localhost/.test(origin)) return;
handled = true;
document.open();
document.write(e.data.html);
document.close();
});
}());
</script>
</body>
</html>

View File

@@ -41,14 +41,10 @@ export function wrapWidgetHtml(html: string, extraClass = ''): string {
`; `;
} }
function escapeSrcdoc(str: string): string { const widgetBodyStore = new Map<string, string>();
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;');
}
export function wrapProWidgetHtml(bodyContent: string): string { function buildWidgetDoc(bodyContent: string): string {
const doc = `<!DOCTYPE html> return `<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
@@ -67,6 +63,49 @@ td{padding:5px 8px;border-bottom:1px solid var(--border-subtle);color:var(--text
</head> </head>
<body>${bodyContent}</body> <body>${bodyContent}</body>
</html>`; </html>`;
}
return `<div class="wm-widget-shell wm-widget-pro"><iframe srcdoc="${escapeSrcdoc(doc)}" sandbox="allow-scripts" style="width:100%;height:400px;border:none;display:block;" title="Interactive widget"></iframe></div>`;
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<HTMLIFrameElement>('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 `<div class="wm-widget-shell wm-widget-pro"><iframe src="/wm-widget-sandbox.html" data-wm-id="${id}" sandbox="allow-scripts" style="width:100%;height:400px;border:none;display:block;" title="Interactive widget"></iframe></div>`;
} }

View File

@@ -16,7 +16,7 @@ const getCacheHeaderValue = (sourcePath) => {
describe('deploy/cache configuration guardrails', () => { describe('deploy/cache configuration guardrails', () => {
it('disables caching for HTML entry routes on Vercel', () => { 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'); assert.equal(spaNoCache, 'no-cache, no-store, must-revalidate');
}); });

View File

@@ -1096,48 +1096,48 @@ describe('PRO widget — store and sanitizer', () => {
); );
}); });
it('wrapProWidgetHtml places CSP as first head child (client-owned skeleton)', () => { it('widget document builder places CSP as first head child (client-owned skeleton)', () => {
const fnIdx = san.indexOf('wrapProWidgetHtml');
const fnBody = san.slice(fnIdx, fnIdx + 800);
assert.ok( assert.ok(
fnBody.includes('Content-Security-Policy'), san.includes('Content-Security-Policy'),
'wrapProWidgetHtml must embed CSP in the head', 'widget sanitizer must embed CSP in the document head',
); );
// CSP meta should come before any style tag // CSP meta should come before any style tag
const cspPos = fnBody.indexOf('Content-Security-Policy'); const cspPos = san.indexOf('Content-Security-Policy');
const stylePos = fnBody.indexOf('<style>'); const stylePos = san.indexOf('<style>');
assert.ok( assert.ok(
cspPos < stylePos, cspPos < stylePos,
'CSP meta must appear before <style> in the generated HTML skeleton', 'CSP meta must appear before <style> in the generated HTML skeleton',
); );
}); });
it('wrapProWidgetHtml CSP has connect-src none (blocks beaconing)', () => { it('widget document builder CSP has connect-src none (blocks beaconing)', () => {
const fnIdx = san.indexOf('wrapProWidgetHtml');
const fnBody = san.slice(fnIdx, fnIdx + 800);
assert.ok( assert.ok(
fnBody.includes("connect-src 'none'"), san.includes("connect-src 'none'"),
"CSP must include connect-src 'none' to block network beaconing from iframe", "CSP must include connect-src 'none' to block network beaconing from iframe",
); );
}); });
it('wrapProWidgetHtml uses escapeSrcdoc for attribute safety', () => { it('wrapProWidgetHtml uses sandbox page src (not srcdoc) for CSP isolation', () => {
const fnIdx = san.indexOf('wrapProWidgetHtml');
const fnBody = san.slice(fnIdx, fnIdx + 500);
assert.ok( assert.ok(
san.includes('escapeSrcdoc'), fnBody.includes('wm-widget-sandbox.html'),
'wrapProWidgetHtml must escape the srcdoc attribute value', 'wrapProWidgetHtml must load the dedicated sandbox page (not srcdoc) to get its own CSP',
);
assert.ok(
!fnBody.includes('srcdoc'),
'wrapProWidgetHtml must NOT use srcdoc — srcdoc inherits parent CSP',
); );
}); });
it('wrapProWidgetHtml injects Chart.js from jsdelivr so new Chart() is available', () => { it('widget document builder injects Chart.js from jsdelivr so new Chart() is available', () => {
const fnIdx = san.indexOf('wrapProWidgetHtml');
const fnBody = san.slice(fnIdx, fnIdx + 1500);
assert.ok( assert.ok(
fnBody.includes('cdn.jsdelivr.net') && fnBody.includes('chart.js'), san.includes('cdn.jsdelivr.net') && san.includes('chart.js'),
'wrapProWidgetHtml must inject Chart.js CDN script so widgets can call new Chart(...)', 'widget sanitizer must inject Chart.js CDN script so widgets can call new Chart(...)',
); );
// Script must appear before </head> so Chart is defined when body scripts run // Script must appear before <body> so Chart is defined when body scripts run
const scriptPos = fnBody.indexOf('chart.js'); const scriptPos = san.indexOf('chart.js');
const bodyPos = fnBody.indexOf('<body>'); const bodyPos = san.indexOf('<body>');
assert.ok( assert.ok(
scriptPos < bodyPos, scriptPos < bodyPos,
'Chart.js script tag must be in <head>, before <body>', 'Chart.js script tag must be in <head>, before <body>',

View File

@@ -7,7 +7,7 @@
"rewrites": [ "rewrites": [
{ "source": "/docs/:match*", "destination": "https://worldmonitor.mintlify.dev/docs/:match*" }, { "source": "/docs/:match*", "destination": "https://worldmonitor.mintlify.dev/docs/:match*" },
{ "source": "/pro", "destination": "/pro/index.html" }, { "source": "/pro", "destination": "/pro/index.html" },
{ "source": "/((?!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).*)", "destination": "/index.html" } { "source": "/((?!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).*)", "destination": "/index.html" }
], ],
"headers": [ "headers": [
{ {
@@ -50,7 +50,7 @@
] ]
}, },
{ {
"source": "/((?!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).*)", "source": "/((?!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).*)",
"headers": [ "headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" } { "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
] ]
@@ -132,6 +132,13 @@
"headers": [ "headers": [
{ "key": "Cache-Control", "value": "public, max-age=86400" } { "key": "Cache-Control", "value": "public, max-age=86400" }
] ]
},
{
"source": "/wm-widget-sandbox.html",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'unsafe-inline'; img-src data:; connect-src 'none';" },
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
} }
] ]
} }