From ae9090948a5f6f4777d2c4ba5802f9b7e5ad54d6 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 13 Jul 2026 15:39:39 +0800 Subject: [PATCH] fix(mindspace): relax published page CSP so agent HTML interactions work Switch default MindSpace HTML delivery to a hybrid inline CSP so onclick handlers and addEventListener pages both run under real HTTP headers, and add Playwright interaction verify beyond HTTP 200 checks. Co-authored-by: Cursor --- mindspace-published-page-csp.mjs | 45 ++-- mindspace-published-page-csp.test.mjs | 27 +- package.json | 1 + scripts/verify-public-page-interaction.mjs | 280 +++++++++++++++++++++ 4 files changed, 323 insertions(+), 30 deletions(-) create mode 100644 scripts/verify-public-page-interaction.mjs diff --git a/mindspace-published-page-csp.mjs b/mindspace-published-page-csp.mjs index 8918ac5..9022214 100644 --- a/mindspace-published-page-csp.mjs +++ b/mindspace-published-page-csp.mjs @@ -12,6 +12,28 @@ export function htmlUsesExternalScriptSrc(html) { return /]*\bsrc\s*=/i.test(String(html ?? '')); } +function buildHybridPublishedPageCsp(html, { extraScriptUrls = [] } = {}) { + const scriptSrc = scriptSrcDirective({ + inline: true, + urls: [ + ...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []), + ...extraScriptUrls, + ], + }); + return [ + "default-src 'none'", + "style-src 'unsafe-inline' https:", + "img-src 'self' data: http: https:", + "font-src 'self' https: data:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'self'", + "frame-ancestors 'self'", + "script-src-attr 'unsafe-inline'", + scriptSrc, + ].join('; '); +} + export function publishedPageCsp( html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}, @@ -21,27 +43,10 @@ export function publishedPageCsp( return publishedPageCspForEmbed(true); } if (wechatShare && isFullHtml) { - const scriptSrc = scriptSrcDirective({ - inline: true, - urls: [ - ...(htmlUsesExternalScriptSrc(html) ? ["'self'"] : []), - 'https://res.wx.qq.com', - ], - }); - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`; + return buildHybridPublishedPageCsp(html, { extraScriptUrls: ['https://res.wx.qq.com'] }); } - if (raw && isFullHtml) { - const scriptSrc = scriptSrcDirective({ - inline: true, - urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [], - }); - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`; - } - if (isFullHtml) { - return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ - hashes: scriptHashes, - urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [], - })}`; + if ((raw || isFullHtml) && isFullHtml) { + return buildHybridPublishedPageCsp(html); } return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } diff --git a/mindspace-published-page-csp.test.mjs b/mindspace-published-page-csp.test.mjs index 6962b3b..a0f5d35 100644 --- a/mindspace-published-page-csp.test.mjs +++ b/mindspace-published-page-csp.test.mjs @@ -7,26 +7,33 @@ const PAGE_DATA_HTML = ` `; -test('publishedPageCsp raw mode allows same-origin external scripts for page-data-client.js', () => { - const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: true }); +const INLINE_ONCLICK_HTML = ` + +`; + +test('publishedPageCsp default full html uses hybrid inline policy with self scripts', () => { + const csp = publishedPageCsp(PAGE_DATA_HTML); assert.match(csp, /script-src 'unsafe-inline' 'self'/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); + assert.doesNotMatch(csp, /'sha256-/); }); -test('publishedPageCsp raw mode keeps inline-only pages restricted', () => { - const html = ''; - const csp = publishedPageCsp(html, { raw: true }); +test('publishedPageCsp default full html allows dynamic onclick handlers', () => { + const csp = publishedPageCsp(INLINE_ONCLICK_HTML); assert.match(csp, /script-src 'unsafe-inline'/); - assert.doesNotMatch(csp, /script-src 'unsafe-inline' 'self'/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); }); -test('publishedPageCsp non-raw full html allows self when external scripts are present', () => { - const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: false }); - assert.match(csp, /script-src 'self'/); +test('publishedPageCsp raw mode matches default hybrid policy for full html', () => { + const rawCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: true }); + const defaultCsp = publishedPageCsp(PAGE_DATA_HTML, { raw: false }); + assert.equal(rawCsp, defaultCsp); }); -test('publishedPageCsp wechatShare mode allows self when page-data-client.js is referenced', () => { +test('publishedPageCsp wechatShare mode allows self and wx bridge scripts', () => { const csp = publishedPageCsp(PAGE_DATA_HTML, { wechatShare: true }); assert.match(csp, /script-src 'unsafe-inline' 'self' https:\/\/res\.wx\.qq\.com/); + assert.match(csp, /script-src-attr 'unsafe-inline'/); }); test('publishedPageCsp wechatShare mode keeps inline-only pages without self', () => { diff --git a/package.json b/package.json index f8ed31c..9e5b039 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", "verify:mindspace-publish-guards:full": "node scripts/verify-mindspace-publish-guards.mjs --with-runtime", "verify:mindspace-page-sync-guards": "node scripts/verify-mindspace-page-sync-guards.mjs", + "verify:public-page-interaction": "node scripts/verify-public-page-interaction.mjs", "verify:page-data": "node --test page-data-acceptance.test.mjs page-data-integration.test.mjs page-data-public-service.test.mjs page-data-ops.test.mjs page-data-delivery-assess.test.mjs mindspace-page-data-finish-guard.test.mjs mindspace-page-data-finish-guard.integration.test.mjs", "verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run", "repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs", diff --git a/scripts/verify-public-page-interaction.mjs b/scripts/verify-public-page-interaction.mjs new file mode 100644 index 0000000..1c0f9c4 --- /dev/null +++ b/scripts/verify-public-page-interaction.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +/** + * Verify MindSpace published pages under real HTTP CSP — interactions, not just HTTP 200. + * + * Requires: local Portal (pnpm dev) + playwright. + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +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 || '1e655eff-69ce-4f56-9d74-b02883e4112a'; + +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}` : ''}`); +} + +function pageUrl(relativePath) { + return `${base}/${PUBLISH_ROOT_DIR}/${userId}/${relativePath}`; +} + +const ONCLICK_FIXTURE = ` + + + + onclick fixture + + + +
idle
+ + +`; + +const ADD_EVENT_LISTENER_FIXTURE = ` + +listener fixture + + +
idle
+ + +`; + +async function writeFixture(relativePath, html) { + const diskPath = path.join(repoRoot, PUBLISH_ROOT_DIR, userId, relativePath); + await fs.mkdir(path.dirname(diskPath), { recursive: true }); + await fs.writeFile(diskPath, html, 'utf8'); +} + +async function launchBrowser() { + const { chromium } = await import('playwright'); + const browser = await chromium.launch({ headless: true }); + return browser; +} + +function attachDiagnostics(page, label) { + const cspViolations = []; + page.on('console', (message) => { + const text = message.text(); + if (/Content Security Policy|Refused to execute|blocked by CSP/i.test(text)) { + cspViolations.push(`${label}: ${text}`); + } + }); + page.on('pageerror', (error) => { + cspViolations.push(`${label}: pageerror ${error.message}`); + }); + return cspViolations; +} + +async function testFixturePage(browser, relativePath, { clickSelector, evaluateReady }) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, relativePath); + await page.goto(pageUrl(relativePath), { waitUntil: 'networkidle', timeout: 25_000 }); + await page.click(clickSelector); + await page.waitForTimeout(250); + const ready = await page.evaluate(evaluateReady); + const csp = (await page.evaluate(() => document.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content)) + ?? ''; + const headerProbe = await fetch(pageUrl(relativePath)).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + return { ready, violations, csp: headerProbe || csp }; +} + +async function testStickyNoteInteractions(browser) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, 'sticky-note-reminder.html'); + const url = pageUrl('public/sticky-note-reminder.html'); + await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); + + await page.click('#prioSelector .priority-option.high'); + const priorityActive = await page.evaluate(() => + document.querySelector('#prioSelector .priority-option.high')?.classList.contains('active'), + ); + + const uniqueTitle = `E2E-${Date.now()}`; + const beforeTotal = Number(await page.textContent('#statTotal')); + await page.fill('#inputTitle', uniqueTitle); + await page.fill('#inputContent', '自动化交互测试内容,用于验证动态 onclick 展开。'); + await page.click('#btnSubmit'); + await page.waitForFunction( + (title) => [...document.querySelectorAll('.card-title')].some((el) => el.textContent?.includes(title)), + uniqueTitle, + { timeout: 10_000 }, + ); + const afterTotal = Number(await page.textContent('#statTotal')); + + await page.click(`.card-title:text("${uniqueTitle}")`); + const cardContent = page.locator('.timeline-item', { hasText: uniqueTitle }).locator('.card-content').first(); + await cardContent.click(); + await page.waitForTimeout(300); + const expanded = await cardContent.evaluate((el) => el.classList.contains('expanded')); + + const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + + return { + violations, + priorityActive, + totalIncreased: afterTotal > beforeTotal, + expanded, + headerCsp, + }; +} + +async function testPlantTreeInteractions(browser) { + const page = await browser.newPage(); + const violations = attachDiagnostics(page, 'plant-tree.html'); + const url = pageUrl('public/plant-tree.html'); + await page.goto(url, { waitUntil: 'networkidle', timeout: 25_000 }); + + const username = `E2E-${Date.now()}`; + await page.fill('#usernameInput', username); + await page.click('#plantBtn'); + await page.waitForFunction( + () => { + const toast = document.querySelector('.toast.show, .toast[style*="opacity: 1"]'); + return toast && /种下/.test(toast.textContent || ''); + }, + { timeout: 10_000 }, + ).catch(async () => { + await page.waitForTimeout(1500); + }); + + const toastText = await page.evaluate(() => { + const nodes = [...document.querySelectorAll('.toast, #toast')]; + return nodes.map((node) => node.textContent?.trim()).filter(Boolean).join(' | '); + }); + await page.waitForFunction( + (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), + username, + { timeout: 10_000 }, + ).catch(() => {}); + const listed = await page.evaluate( + (name) => [...document.querySelectorAll('.activity-item .name')].some((el) => el.textContent?.includes(name)), + username, + ); + + const headerCsp = await fetch(url).then((res) => res.headers.get('content-security-policy') ?? ''); + await page.close(); + + return { + violations, + toastText, + listed, + headerCsp, + }; +} + +async function main() { + console.log(`\n=== MindSpace public page interaction verify @ ${base} ===\n`); + + 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; + } + + await writeFixture('public/csp-onclick-fixture.html', ONCLICK_FIXTURE); + await writeFixture('public/csp-listener-fixture.html', ADD_EVENT_LISTENER_FIXTURE); + + let browser; + try { + browser = await launchBrowser(); + } catch (error) { + record('playwright_available', false, { detail: error?.message || String(error) }); + process.exitCode = 1; + return; + } + record('playwright_available', true, { detail: 'chromium launched' }); + + const onclick = await testFixturePage(browser, 'public/csp-onclick-fixture.html', { + clickSelector: '#btn', + evaluateReady: () => ({ + bodyClicked: document.body.dataset.clicked === '1', + listenerStatus: document.getElementById('status')?.textContent ?? '', + }), + }); + record('fixture_onclick_attribute_executes', onclick.ready.bodyClicked, { + detail: JSON.stringify(onclick.ready), + }); + record('fixture_onclick_with_listener_both_work', onclick.ready.listenerStatus === 'listener-ok', { + detail: onclick.ready.listenerStatus, + }); + record('fixture_onclick_no_csp_violations', onclick.violations.length === 0, { + detail: onclick.violations.join(' | ') || 'none', + }); + record('fixture_onclick_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(onclick.csp), { + detail: onclick.csp.match(/script-src[^;]+/)?.[0] ?? onclick.csp.slice(0, 120), + }); + + const listener = await testFixturePage(browser, 'public/csp-listener-fixture.html', { + clickSelector: '#btn', + evaluateReady: () => ({ + bodyClicked: document.body.dataset.clicked === '1', + status: document.getElementById('status')?.textContent ?? '', + }), + }); + record('fixture_add_event_listener_executes', listener.ready.status === 'ok', { + detail: JSON.stringify(listener.ready), + }); + record('fixture_add_event_listener_no_csp_violations', listener.violations.length === 0, { + detail: listener.violations.join(' | ') || 'none', + }); + + const sticky = await testStickyNoteInteractions(browser); + record('sticky_note_priority_click_works', sticky.priorityActive, { detail: String(sticky.priorityActive) }); + record('sticky_note_submit_increases_total', sticky.totalIncreased, { detail: String(sticky.totalIncreased) }); + record('sticky_note_dynamic_onclick_expand_works', sticky.expanded, { + detail: sticky.expanded ? 'card expanded' : 'onclick expand blocked', + }); + record('sticky_note_no_csp_violations', sticky.violations.length === 0, { + detail: sticky.violations.join(' | ') || 'none', + }); + record('sticky_note_csp_allows_inline', /script-src[^;]*'unsafe-inline'/.test(sticky.headerCsp), { + detail: sticky.headerCsp.match(/script-src[^;]+/)?.[0] ?? sticky.headerCsp.slice(0, 120), + }); + + const plant = await testPlantTreeInteractions(browser); + record('plant_tree_submit_shows_toast', /种下/.test(plant.toastText), { detail: plant.toastText || 'empty' }); + record('plant_tree_record_listed', plant.listed, { detail: String(plant.listed) }); + record('plant_tree_no_csp_violations', plant.violations.length === 0, { + detail: plant.violations.join(' | ') || 'none', + }); + + await browser.close(); + + const ok = checks.every((item) => item.ok); + console.log(`\n=== ${ok ? 'ALL PASS — published pages interact correctly under CSP' : 'SOME FAILED'} (${checks.filter((item) => item.ok).length}/${checks.length}) ===\n`); + if (!ok) { + console.log('Failed checks:'); + for (const item of checks.filter((entry) => !entry.ok)) { + console.log(` - ${item.name}${item.detail ? `: ${item.detail}` : ''}`); + } + console.log('\nTip: restart dev server (pnpm dev) after CSP policy changes.\n'); + } + process.exitCode = ok ? 0 : 1; +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});