fix(mindspace): guard published pages against blocked CDN scripts
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>
This commit is contained in:
@@ -92,6 +92,15 @@ const HTML_RULES = [
|
||||
mask: () => '<script>…</script>',
|
||||
replacement: () => '',
|
||||
},
|
||||
{
|
||||
type: 'html_external_script',
|
||||
label: '外部 CDN 脚本',
|
||||
riskLevel: 'high',
|
||||
blocking: true,
|
||||
pattern: /<script\b[^>]*\bsrc\s*=\s*(['"])((?:https?:)?\/\/[^'"]+)\1/gi,
|
||||
mask: (_value, _quote, src) => String(src).slice(0, 72),
|
||||
replacement: () => '',
|
||||
},
|
||||
{
|
||||
type: 'html_inline_handler',
|
||||
label: '内联事件',
|
||||
|
||||
@@ -67,6 +67,24 @@ test('scanContent can allow interactive html forms as acknowledgeable warnings',
|
||||
);
|
||||
});
|
||||
|
||||
test('scanContent blocks unresolved external script src even when html active content is allowed', () => {
|
||||
const result = scanContent(
|
||||
'<script src="https://cdn.example.com/app.js"></script><script>init()</script>',
|
||||
{ format: 'html', allowHtmlActiveContent: true },
|
||||
);
|
||||
assert.equal(result.allowed, false);
|
||||
assert.ok(result.findings.some((finding) => finding.type === 'html_external_script' && finding.blocking));
|
||||
});
|
||||
|
||||
test('scanContent allows same-origin script src with active html content', () => {
|
||||
const result = scanContent(
|
||||
'<script src="/assets/page-data-client.js"></script><script>init()</script>',
|
||||
{ format: 'html', allowHtmlActiveContent: true },
|
||||
);
|
||||
assert.equal(result.allowed, true);
|
||||
assert.ok(!result.findings.some((finding) => finding.type === 'html_external_script'));
|
||||
});
|
||||
|
||||
test('redactContent masks secrets and strips unsafe html', () => {
|
||||
const result = redactContent(
|
||||
'手机号 13800138000\n邮箱 john@example.com\n<script>alert(1)</script>\nkey=sk_test_1234567890abcdef',
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
|
||||
import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs';
|
||||
import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
|
||||
import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
|
||||
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
|
||||
@@ -352,6 +353,7 @@ async function prepareHtmlPublishContent({
|
||||
h5Root = null,
|
||||
}) {
|
||||
let publishContent = (await localizeGoogleFontsCss(html)).html;
|
||||
publishContent = rewriteKnownCdnScriptSources(publishContent).html;
|
||||
publishContent = replaceImgproxyStandardImageReferences(
|
||||
publishContent,
|
||||
resolvePublicBaseUrl(),
|
||||
|
||||
@@ -709,6 +709,27 @@ test('prepareHtmlPublishContent rewrites workspace public asset paths relative t
|
||||
assert.doesNotMatch(prepared, /"cover":"public\//);
|
||||
});
|
||||
|
||||
test('prepareHtmlPublishContent rewrites known chart.js cdn scripts to platform asset', async () => {
|
||||
const prepared = await publicationInternals.prepareHtmlPublishContent({
|
||||
pool: {
|
||||
async query() {
|
||||
return [[]];
|
||||
},
|
||||
},
|
||||
userId: 'user-1',
|
||||
html: '<!doctype html><body><script src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script></body></html>',
|
||||
ownerSlug: 'john',
|
||||
urlSlug: 'survey-admin',
|
||||
htmlRelativePath: 'public/survey-admin.html',
|
||||
absoluteStoragePath: () => {
|
||||
throw new Error('should not read private storage');
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(prepared, /src="\/assets\/chart\.umd\.min\.js"/);
|
||||
assert.doesNotMatch(prepared, /cdn\.jsdelivr\.net/);
|
||||
});
|
||||
|
||||
test('rewritePublicationCanonicalAssetUrls rewrites workspace tmp image paths for /u pages routes', () => {
|
||||
const html = [
|
||||
'<img src=".tmp-images/one.jpg">',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
const SCRIPT_SRC_PATTERN = /<script\b([^>]*)\bsrc\s*=\s*(['"])([^'"]+)\2([^>]*)>/gi;
|
||||
|
||||
export const KNOWN_PLATFORM_SCRIPT_ALIASES = [
|
||||
{
|
||||
id: 'chart.js',
|
||||
target: '/assets/chart.umd.min.js',
|
||||
patterns: [
|
||||
/cdn\.jsdelivr\.net\/npm\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js/i,
|
||||
/cdnjs\.cloudflare\.com\/ajax\/libs\/Chart\.js\/[^/]+\/chart\.(?:umd\.)?min\.js/i,
|
||||
/unpkg\.com\/chart\.js(?:@[^/]+)?\/dist\/chart\.(?:umd\.)?min\.js/i,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeScriptUrl(value) {
|
||||
return String(value ?? '').trim().replace(/&/g, '&');
|
||||
}
|
||||
|
||||
export function isExternalScriptSrc(src) {
|
||||
const normalized = normalizeScriptUrl(src);
|
||||
if (!normalized) return false;
|
||||
if (/^https?:\/\//i.test(normalized)) return true;
|
||||
if (/^\/\//.test(normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveKnownPlatformScriptTarget(src) {
|
||||
const normalized = normalizeScriptUrl(src);
|
||||
if (!isExternalScriptSrc(normalized)) return null;
|
||||
const probe = normalized.startsWith('//') ? `https:${normalized}` : normalized;
|
||||
for (const alias of KNOWN_PLATFORM_SCRIPT_ALIASES) {
|
||||
if (alias.patterns.some((pattern) => pattern.test(probe))) {
|
||||
return alias.target;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findExternalScriptSources(html) {
|
||||
const sources = [];
|
||||
const seen = new Set();
|
||||
for (const match of String(html ?? '').matchAll(SCRIPT_SRC_PATTERN)) {
|
||||
const src = normalizeScriptUrl(match[3]);
|
||||
if (!isExternalScriptSrc(src) || seen.has(src)) continue;
|
||||
seen.add(src);
|
||||
sources.push(src);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function rewriteKnownCdnScriptSources(html) {
|
||||
const rewrites = [];
|
||||
const next = String(html ?? '').replace(
|
||||
SCRIPT_SRC_PATTERN,
|
||||
(full, before, quote, src, after) => {
|
||||
const normalized = normalizeScriptUrl(src);
|
||||
const target = resolveKnownPlatformScriptTarget(normalized);
|
||||
if (!target) return full;
|
||||
rewrites.push({ from: normalized, to: target });
|
||||
return `<script${before}src=${quote}${target}${quote}${after}>`;
|
||||
},
|
||||
);
|
||||
return {
|
||||
html: next,
|
||||
rewrittenCount: rewrites.length,
|
||||
rewrites,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
findExternalScriptSources,
|
||||
isExternalScriptSrc,
|
||||
rewriteKnownCdnScriptSources,
|
||||
} from './mindspace-published-script-localize.mjs';
|
||||
|
||||
test('isExternalScriptSrc detects http and protocol-relative urls', () => {
|
||||
assert.equal(isExternalScriptSrc('https://cdn.jsdelivr.net/npm/chart.js'), true);
|
||||
assert.equal(isExternalScriptSrc('//cdn.jsdelivr.net/npm/chart.js'), true);
|
||||
assert.equal(isExternalScriptSrc('/assets/page-data-client.js'), false);
|
||||
assert.equal(isExternalScriptSrc('assets/chart.umd.min.js'), false);
|
||||
});
|
||||
|
||||
test('rewriteKnownCdnScriptSources rewrites chart.js cdn urls to platform asset', () => {
|
||||
const source = `<!doctype html><html><body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script src="//cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
</body></html>`;
|
||||
const result = rewriteKnownCdnScriptSources(source);
|
||||
assert.equal(result.rewrittenCount, 2);
|
||||
assert.match(result.html, /src="\/assets\/chart\.umd\.min\.js"/g);
|
||||
assert.doesNotMatch(result.html, /cdn\.jsdelivr\.net/);
|
||||
assert.doesNotMatch(result.html, /cdnjs\.cloudflare\.com/);
|
||||
assert.match(result.html, /src="\/assets\/page-data-client\.js"/);
|
||||
});
|
||||
|
||||
test('findExternalScriptSources lists unresolved external scripts only', () => {
|
||||
const source = `<html><body>
|
||||
<script src="https://cdn.example.com/app.js"></script>
|
||||
<script src="assets/local.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script>
|
||||
</body></html>`;
|
||||
const rewritten = rewriteKnownCdnScriptSources(source).html;
|
||||
assert.deepEqual(findExternalScriptSources(rewritten), ['https://cdn.example.com/app.js']);
|
||||
});
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
|
||||
"test:memind": "node scripts/run-memind-tests.mjs",
|
||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
|
||||
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
|
||||
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
|
||||
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -0,0 +1,196 @@
|
||||
#!/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;
|
||||
});
|
||||
@@ -109,6 +109,7 @@ import {
|
||||
stripPublicationHtmlCspMeta,
|
||||
} from './plaza-embed.mjs';
|
||||
import { publishedPageCsp } from './mindspace-published-page-csp.mjs';
|
||||
import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs';
|
||||
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
|
||||
import {
|
||||
analyzeChatMessageForSave,
|
||||
@@ -5833,6 +5834,7 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
|
||||
return;
|
||||
}
|
||||
html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html);
|
||||
html = rewriteKnownCdnScriptSources(html).html;
|
||||
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
|
||||
const embed = isPlazaEmbedRequest(req.query);
|
||||
if (embed) {
|
||||
|
||||
@@ -164,6 +164,17 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
```
|
||||
|
||||
- **第三方 JS 库**(Chart.js、ECharts 等)禁止写 CDN `https://...`;发布页 CSP 只允许同源脚本。优先用平台预置路径,或下载到 `public/assets/` 后用相对路径引用:
|
||||
|
||||
```html
|
||||
<!-- 推荐:平台已预置 Chart.js -->
|
||||
<script src="/assets/chart.umd.min.js"></script>
|
||||
<!-- 或页面自有副本 -->
|
||||
<script src="assets/chart.umd.min.js"></script>
|
||||
```
|
||||
|
||||
平台会在发布/访问时自动把常见 Chart.js CDN 改写为 `/assets/chart.umd.min.js`;其它未知 CDN 会在发布检查中被拦截。
|
||||
|
||||
### 4. 发布并绑定页面(推荐)
|
||||
|
||||
用 `private_data_bind_workspace_page` 一步完成:**创建/更新页面记录 → 发布 → 写入 Page Data 策略**。
|
||||
@@ -258,10 +269,11 @@ CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
5. **禁止**创建 `scripts/*-api.mjs`、Express 服务、或监听独立端口(如 `8899`)
|
||||
6. **禁止** HTML 中硬编码 `http://127.0.0.1:端口` 或自定义 `/api/survey/*`
|
||||
7. **禁止**在 HTML 中使用 `onclick` / `oninput` 等内联事件属性(MindSpace 发布页 CSP 不允许);改用 `addEventListener`
|
||||
8. **禁止**在页面 JS 中直接使用 `better-sqlite3` 或读取 `.sqlite` 文件路径
|
||||
9. **禁止**只 `CREATE TABLE` 而不 `private_data_register_dataset`
|
||||
10. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
|
||||
11. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
|
||||
8. **禁止**用 CDN `https://...` 引用 `<script src>`(发布页 CSP 仅允许同源;Chart.js 用 `/assets/chart.umd.min.js` 或 `public/assets/` 本地文件)
|
||||
9. **禁止**在页面 JS 中直接使用 `better-sqlite3` 或读取 `.sqlite` 文件路径
|
||||
10. **禁止**只 `CREATE TABLE` 而不 `private_data_register_dataset`
|
||||
11. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
|
||||
12. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
|
||||
|
||||
## 交付前自检
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
|
||||
7. 默认只生成 HTML;不要在没有明确需求时强制生成 Word、PDF、长图等伴生文件
|
||||
8. 只有用户明确要求 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏)
|
||||
9. 只有用户明确要求**长图下载**时:必须先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成同目录 `public/<页面名>.long.png`,再返回长图预览与下载链接;禁止把 `.thumbnail.svg` 当成长图
|
||||
10. **禁止**在 HTML 中用 CDN `https://...` 引用 `<script src>`;MindSpace 发布页 CSP 仅允许同源脚本。常用库优先用平台路径(如 Chart.js → `/assets/chart.umd.min.js`),或下载到 `public/assets/` 后相对引用
|
||||
|
||||
详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user