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 <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-13 15:39:39 +08:00
parent 6ef3ea89c6
commit ae9090948a
4 changed files with 323 additions and 30 deletions
+25 -20
View File
@@ -12,6 +12,28 @@ export function htmlUsesExternalScriptSrc(html) {
return /<script\b[^>]*\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'";
}
+17 -10
View File
@@ -7,26 +7,33 @@ const PAGE_DATA_HTML = `<!doctype html><html><head></head><body>
<script>var client = MindSpacePageData.createClient({ apiBase: '/api' });</script>
</body></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 = `<!doctype html><html><body>
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
</body></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 = '<!doctype html><html><body><script>console.log(1)</script></body></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', () => {
+1
View File
@@ -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",
+280
View File
@@ -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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>onclick fixture</title>
</head>
<body>
<button id="btn" onclick="document.body.dataset.clicked='1'">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.getElementById('status').textContent = 'listener-ok';
});
</script>
</body>
</html>`;
const ADD_EVENT_LISTENER_FIXTURE = `<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>listener fixture</title></head>
<body>
<button id="btn">点我</button>
<div id="status">idle</div>
<script>
document.getElementById('btn').addEventListener('click', function () {
document.body.dataset.clicked = '1';
document.getElementById('status').textContent = 'ok';
});
</script>
</body>
</html>`;
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;
});