#!/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 = ` CSP Chart CDN 模拟测试

Chart.js CDN 模拟页

loading…
`; const UNKNOWN_CDN_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; });