This commit is contained in:
2026-10-16 16:34:15 +08:00
parent 7a3769670b
commit ff366a4daf
80 changed files with 9961 additions and 888 deletions

View File

@@ -0,0 +1,123 @@
// index.html —— CSP 收紧(audit P0-2)
//
// 背景:原 CSP `script-src 'self' 'unsafe-inline'` —— 注释承认仅 importmap
// 需要 inline,但 'unsafe-inline' 留下 XSS 后续路径(任何位置注入的 inline
// script 都被放行)。
//
// 修复:给 importmap 内联块生成 SHA-256 hash;CSP 改
// script-src 'self' 'sha256-<hash>='
// 把 'unsafe-inline' 从 script-src 移除。hash 必须与 importmap 当前实际内容
// 匹配(任何 importmap 调整后必须重算 + 同步 CSP,否则 importmap 被 CSP
// 拦截,renderer 启动会「Failed to resolve module specifier」)。
//
// 注:style-src 的 'unsafe-inline' 保留 —— marked 渲染的代码块用到 inline
// style(CSP 收紧会破坏阅读视图)。本测试只盯 script-src。
//
// 测试策略:直接读 index.html 文件,解析 <meta http-equiv="Content-Security-Policy">
// 和 <script type="importmap"> 块,独立用 Node crypto 重算 hash,断言:
// - script-src 不含 'unsafe-inline'
// - script-src 含 'sha256-...' 形式
// - CSP 里的 hash == importmap inner content 的 SHA-256
// - importmap 改动 → hash 自动失效(防 drift)
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const INDEX_HTML = resolve(__dirname, '../../index.html');
function readIndexHtml() {
return readFileSync(INDEX_HTML, 'utf8');
}
function extractCsp(html) {
// content 属性里含单引号 ('self') + 哈希等号,不能用 [^"']+ 简单排除;
// 用更稳的「非贪婪 + 显式终止」:content="..." 直到遇到 " 后接空白/>/换行。
const m = html.match(/<meta\s+http-equiv=["']Content-Security-Policy["']\s+content=(["'])([\s\S]*?)\1\s*>/i);
if (!m) throw new Error('未找到 <meta http-equiv="Content-Security-Policy">');
return m[2];
}
function parseCspDirectives(csp) {
// 简单解析:split by ';',每个 directive 按空格 split key / values。
// 值可能带单/双引号(CSP source-list 语法),解析时去掉外层引号便于后续比对。
/** @type {Record<string, string[]>} */
const out = {};
for (const raw of csp.split(';')) {
const trimmed = raw.trim();
if (!trimmed) continue;
const parts = trimmed.split(/\s+/);
const key = parts.shift();
out[key] = parts.map((v) => {
// 去外层 ' 或 "(CSP value 内部不含嵌套引号,simple trim 安全)
if ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))) {
return v.slice(1, -1);
}
return v;
});
}
return out;
}
function extractImportmapContent(html) {
const m = html.match(/<script\s+type=["']importmap["']\s*>([\s\S]*?)<\/script>/i);
if (!m) throw new Error('未找到 <script type="importmap">');
return m[1];
}
function sha256Base64(content) {
// 浏览器 HTML tokenizer 在算 CSP inline-script hash 前,会把脚本内容里的
// \r\n / \r 归一化成 \n(这是 2026-10-16 importmap 被 CSP 拦截的真因:仓库
// 源文件是 CRLF,但 CSP 必须放 LF 归一化后的 hash)。这里先归一化再算,
// 与浏览器行为一致,否则测试会在「文件 CRLF」时算出错的 hash 还判定通过。
const normalized = content.replace(/\r\n?/g, '\n');
return createHash('sha256').update(normalized).digest('base64');
}
describe('index.html CSP —— script-src 不含 unsafe-inline(audit P0-2)', () => {
it('CSP meta 标签存在', () => {
const html = readIndexHtml();
const csp = extractCsp(html);
expect(csp).toBeTruthy();
});
it("script-src 不含 'unsafe-inline'", () => {
// parseCspDirectives 已剥外层引号 → 直接比对 'unsafe-inline'(无引号)。
// 用 .some 而非 .toContain:避免未来 CSP 拼成 host-source-list 误判。
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
expect(directives['script-src']).toBeDefined();
expect(directives['script-src'].some((v) => v === 'unsafe-inline')).toBe(false);
});
it('script-src 含 self + 至少一个 sha256-XXX=', () => {
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
const scriptSrc = directives['script-src'];
expect(scriptSrc).toContain('self');
const hashEntries = scriptSrc.filter((v) => /^sha256-/.test(v));
expect(hashEntries.length).toBeGreaterThanOrEqual(1);
});
it('CSP 里的 sha256 hash == importmap inner content 的 SHA-256', () => {
const html = readIndexHtml();
const directives = parseCspDirectives(extractCsp(html));
const importmapContent = extractImportmapContent(html);
const expectedHash = sha256Base64(importmapContent);
const expectedEntry = `sha256-${expectedHash}`;
const scriptSrc = directives['script-src'];
expect(scriptSrc).toContain(expectedEntry);
});
it('importmap 内容变更 → hash 自动失效(防 drift,文档化契约)', () => {
// 这条不是失败用例,是「设计意图」文档:
// 任何 importmap 调整(新增/删除/改路径)后必须重算 SHA-256 并同步 CSP。
// 改完 importmap 不重算 hash → 上面那条测试失败,提示开发者。
// 不需要断言(已由上一条隐含覆盖),存在仅为了让读者明白这条测试的意图。
expect(true).toBe(true);
});
});