mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
21
public/wm-widget-sandbox.html
Normal file
21
public/wm-widget-sandbox.html
Normal 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>
|
||||
@@ -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<string, string>();
|
||||
|
||||
export function wrapProWidgetHtml(bodyContent: string): string {
|
||||
const doc = `<!DOCTYPE html>
|
||||
function buildWidgetDoc(bodyContent: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -67,6 +63,49 @@ td{padding:5px 8px;border-bottom:1px solid var(--border-subtle);color:var(--text
|
||||
</head>
|
||||
<body>${bodyContent}</body>
|
||||
</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>`;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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('<style>');
|
||||
const cspPos = san.indexOf('Content-Security-Policy');
|
||||
const stylePos = san.indexOf('<style>');
|
||||
assert.ok(
|
||||
cspPos < stylePos,
|
||||
'CSP meta must appear before <style> in the generated HTML skeleton',
|
||||
);
|
||||
});
|
||||
|
||||
it('wrapProWidgetHtml CSP has connect-src none (blocks beaconing)', () => {
|
||||
const fnIdx = san.indexOf('wrapProWidgetHtml');
|
||||
const fnBody = san.slice(fnIdx, fnIdx + 800);
|
||||
it('widget document builder CSP has connect-src none (blocks beaconing)', () => {
|
||||
assert.ok(
|
||||
fnBody.includes("connect-src 'none'"),
|
||||
san.includes("connect-src 'none'"),
|
||||
"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(
|
||||
san.includes('escapeSrcdoc'),
|
||||
'wrapProWidgetHtml must escape the srcdoc attribute value',
|
||||
fnBody.includes('wm-widget-sandbox.html'),
|
||||
'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', () => {
|
||||
const fnIdx = san.indexOf('wrapProWidgetHtml');
|
||||
const fnBody = san.slice(fnIdx, fnIdx + 1500);
|
||||
it('widget document builder injects Chart.js from jsdelivr so new Chart() is available', () => {
|
||||
assert.ok(
|
||||
fnBody.includes('cdn.jsdelivr.net') && fnBody.includes('chart.js'),
|
||||
'wrapProWidgetHtml must inject Chart.js CDN script so widgets can call new Chart(...)',
|
||||
san.includes('cdn.jsdelivr.net') && san.includes('chart.js'),
|
||||
'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
|
||||
const scriptPos = fnBody.indexOf('chart.js');
|
||||
const bodyPos = fnBody.indexOf('<body>');
|
||||
// Script must appear before <body> so Chart is defined when body scripts run
|
||||
const scriptPos = san.indexOf('chart.js');
|
||||
const bodyPos = san.indexOf('<body>');
|
||||
assert.ok(
|
||||
scriptPos < bodyPos,
|
||||
'Chart.js script tag must be in <head>, before <body>',
|
||||
|
||||
11
vercel.json
11
vercel.json
@@ -7,7 +7,7 @@
|
||||
"rewrites": [
|
||||
{ "source": "/docs/:match*", "destination": "https://worldmonitor.mintlify.dev/docs/:match*" },
|
||||
{ "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": [
|
||||
{
|
||||
@@ -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": [
|
||||
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
|
||||
]
|
||||
@@ -132,6 +132,13 @@
|
||||
"headers": [
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user