aede1e6fcb
Pre-bundle Chart.js, auto-rewrite common CDN references at publish and serve time, and block unknown external script src during publication scans so interactive dashboards keep working under CSP. Co-authored-by: Cursor <cursoragent@cursor.com>
197 lines
7.8 KiB
JavaScript
197 lines
7.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Simulate the Chart.js CDN + MindSpace CSP issue and verify platform guards.
|
|
*/
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { scanContent } from '../mindspace-content-scan.mjs';
|
|
import { rewriteKnownCdnScriptSources } from '../mindspace-published-script-localize.mjs';
|
|
import { publishedPageCsp } from '../mindspace-published-page-csp.mjs';
|
|
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const repoRoot = path.resolve(__dirname, '..');
|
|
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
|
|
const userId = process.env.VERIFY_USER_ID || '1c99b83b-0454-474f-a5d2-129d34506a32';
|
|
const testFileName = 'csp-chart-cdn-sim-test.html';
|
|
const testRelativePath = `public/${testFileName}`;
|
|
const testDiskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, testRelativePath);
|
|
const testUrl = `${base}/${PUBLISH_ROOT_DIR}/${userId}/${testRelativePath}`;
|
|
|
|
const checks = [];
|
|
|
|
function record(name, ok, details = {}) {
|
|
checks.push({ name, ok: Boolean(ok), ...details });
|
|
const mark = ok ? 'PASS' : 'FAIL';
|
|
console.log(`${mark} ${name}${details.detail ? ` — ${details.detail}` : ''}`);
|
|
}
|
|
|
|
const CDN_HTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>CSP Chart CDN 模拟测试</title>
|
|
<style>body{font-family:sans-serif;padding:24px}#status{margin-top:16px;padding:12px;border-radius:8px;background:#eef7f1}</style>
|
|
</head>
|
|
<body>
|
|
<h1>Chart.js CDN 模拟页</h1>
|
|
<canvas id="chart" width="480" height="240"></canvas>
|
|
<div id="status">loading…</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
var el = document.getElementById('status');
|
|
try {
|
|
if (typeof Chart === 'undefined') {
|
|
el.textContent = 'FAIL: Chart is undefined (CDN blocked by CSP)';
|
|
el.style.background = '#fde8e8';
|
|
return;
|
|
}
|
|
new Chart(document.getElementById('chart'), {
|
|
type: 'bar',
|
|
data: {
|
|
labels: ['A', 'B', 'C'],
|
|
datasets: [{ label: '模拟', data: [3, 5, 2], backgroundColor: '#3b82f6' }],
|
|
},
|
|
options: { plugins: { legend: { display: false } } },
|
|
});
|
|
el.textContent = 'OK: Chart rendered';
|
|
el.style.background = '#e8f7ee';
|
|
} catch (error) {
|
|
el.textContent = 'FAIL: ' + (error && error.message ? error.message : String(error));
|
|
el.style.background = '#fde8e8';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
|
|
const UNKNOWN_CDN_HTML = `<!doctype html><html><body><script src="https://cdn.example.com/unknown-lib.js"></script></body></html>`;
|
|
|
|
async function ensureTestPageOnDisk() {
|
|
await fs.mkdir(path.dirname(testDiskPath), { recursive: true });
|
|
await fs.writeFile(testDiskPath, CDN_HTML, 'utf8');
|
|
}
|
|
|
|
async function fetchPublishedPage() {
|
|
const res = await fetch(testUrl, { redirect: 'manual' });
|
|
const html = await res.text();
|
|
return {
|
|
status: res.status,
|
|
html,
|
|
csp: res.headers.get('content-security-policy') ?? '',
|
|
};
|
|
}
|
|
|
|
async function fetchAsset(urlPath) {
|
|
const res = await fetch(`${base}${urlPath}`, { redirect: 'manual' });
|
|
return { status: res.status, contentType: res.headers.get('content-type') ?? '' };
|
|
}
|
|
|
|
async function runBrowserCheck() {
|
|
try {
|
|
const { chromium } = await import('playwright');
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage();
|
|
await page.goto(testUrl, { waitUntil: 'networkidle', timeout: 20_000 });
|
|
const statusText = await page.locator('#status').textContent();
|
|
const chartDefined = await page.evaluate(() => typeof Chart !== 'undefined');
|
|
await browser.close();
|
|
return { statusText: String(statusText ?? '').trim(), chartDefined };
|
|
} catch (error) {
|
|
return { error: error?.message || String(error) };
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`\n=== Chart.js CDN / CSP guard simulation @ ${base} ===\n`);
|
|
|
|
// 0. Server up
|
|
const health = await fetch(`${base}/auth/status`).then((r) => r.ok).catch(() => false);
|
|
record('portal_running', health, { detail: health ? 'auth/status OK' : 'server down' });
|
|
if (!health) {
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// 1. Static rewrite logic (offline)
|
|
const rewritten = rewriteKnownCdnScriptSources(CDN_HTML);
|
|
record('offline_rewrite_chart_cdn', rewritten.rewrittenCount === 1, {
|
|
detail: `rewrites=${rewritten.rewrittenCount}`,
|
|
});
|
|
record('offline_rewrite_target', /\/assets\/chart\.umd\.min\.js/.test(rewritten.html), {
|
|
detail: '/assets/chart.umd.min.js',
|
|
});
|
|
|
|
// 2. Publish scan blocks unknown CDN
|
|
const unknownScan = scanContent(UNKNOWN_CDN_HTML, { format: 'html', allowHtmlActiveContent: true });
|
|
record('publish_scan_blocks_unknown_cdn', !unknownScan.allowed, {
|
|
detail: unknownScan.findings.map((f) => f.type).join(', '),
|
|
});
|
|
|
|
// 3. Publish scan allows rewritten chart page
|
|
const rewrittenScan = scanContent(rewritten.html, { format: 'html', allowHtmlActiveContent: true });
|
|
record('publish_scan_allows_rewritten_chart_page', rewrittenScan.allowed, {
|
|
detail: rewrittenScan.status,
|
|
});
|
|
|
|
// 4. Write simulated user page (still contains CDN on disk)
|
|
await ensureTestPageOnDisk();
|
|
record('write_simulated_page_with_cdn', true, { detail: testRelativePath });
|
|
|
|
// 5. HTTP delivery rewrites CDN away
|
|
const delivered = await fetchPublishedPage();
|
|
record('http_page_status_200', delivered.status === 200, { detail: `HTTP ${delivered.status}` });
|
|
const cdnRemoved = !/cdn\.jsdelivr\.net/i.test(delivered.html);
|
|
record('http_delivery_removes_cdn_url', cdnRemoved, {
|
|
detail: cdnRemoved ? 'cdn.jsdelivr.net absent' : 'cdn still present',
|
|
});
|
|
const platformScript = /src="\/assets\/chart\.umd\.min\.js"/.test(delivered.html);
|
|
record('http_delivery_injects_platform_chart_script', platformScript, {
|
|
detail: '/assets/chart.umd.min.js',
|
|
});
|
|
|
|
// 6. CSP allows same-origin scripts
|
|
const csp = publishedPageCsp(delivered.html, { scriptHashes: [] });
|
|
const cspAllowsSelf = /script-src[^;]*'self'/.test(csp);
|
|
record('csp_allows_self_scripts', cspAllowsSelf, { detail: csp.match(/script-src[^;]+/)?.[0] ?? csp });
|
|
const headerCspAllowsSelf = /script-src[^;]*'self'/.test(delivered.csp);
|
|
record('response_csp_header_allows_self', headerCspAllowsSelf, {
|
|
detail: delivered.csp.match(/script-src[^;]+/)?.[0] ?? delivered.csp.slice(0, 120),
|
|
});
|
|
|
|
// 7. Platform chart asset reachable
|
|
const asset = await fetchAsset('/assets/chart.umd.min.js');
|
|
record('platform_chart_asset_http_200', asset.status === 200, {
|
|
detail: `HTTP ${asset.status} ${asset.contentType}`,
|
|
});
|
|
|
|
// 8. Browser renders chart (real user experience)
|
|
const browser = await runBrowserCheck();
|
|
if (browser.error) {
|
|
record('browser_chart_renders', false, { detail: `playwright unavailable: ${browser.error}` });
|
|
} else {
|
|
record('browser_chart_global_defined', browser.chartDefined, { detail: String(browser.chartDefined) });
|
|
record('browser_chart_renders', /OK: Chart rendered/.test(browser.statusText), {
|
|
detail: browser.statusText || 'empty status',
|
|
});
|
|
}
|
|
|
|
const ok = checks.every((c) => c.ok);
|
|
console.log(`\n=== ${ok ? 'ALL PASS — CDN/CSP issue should not recur for Chart.js' : 'SOME FAILED'} (${checks.filter((c) => c.ok).length}/${checks.length}) ===\n`);
|
|
if (!ok) {
|
|
console.log('Failed checks:');
|
|
for (const item of checks.filter((c) => !c.ok)) {
|
|
console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`);
|
|
}
|
|
console.log('\nTip: restart dev server (pnpm dev) if http_delivery_* failed but offline_rewrite passed.\n');
|
|
}
|
|
process.exitCode = ok ? 0 : 1;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|