fix(security): harden bookmark parsing and CSV export
This commit is contained in:
129
SECURITY_AUDIT.md
Normal file
129
SECURITY_AUDIT.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 安全审核报告
|
||||
|
||||
审核日期:2026-10-14。范围:`F:\Codes\HTML\convert` 当前目录全部文件,包括隐藏文件。审核开始时目录仅有 `index.html`(68 行);全文人工审查并进行了危险 API、网络调用、凭据模式扫描。后续也审查了新增的回归测试。
|
||||
|
||||
## 1. 项目概况与依赖
|
||||
|
||||
项目是本地离线书签 HTML → CSV 转换器,使用 HTML、CSS、原生浏览器 JavaScript;无框架、后端、数据库、浏览器扩展、桌面 IPC、移动端代码或 CI/CD 配置。
|
||||
|
||||
| 清单项 | 结果 |
|
||||
| --- | --- |
|
||||
| 直接第三方依赖及实际版本 | 0;没有 npm/CDN/其他第三方库 |
|
||||
| 锁文件及关键间接依赖实际版本 | 无锁文件,0 个可识别的间接依赖 |
|
||||
| 包管理器、依赖清单 | 无;未发现 package.json、requirements.txt、go.mod、Cargo.toml、pom.xml、Podfile 或同类文件 |
|
||||
| 构建、现成测试 | 无;直接由浏览器打开 HTML |
|
||||
| 停止维护、长期未更新、疑似仿冒包 | 无适用第三方包 |
|
||||
|
||||
因此没有需要逐包检索的名称或版本,也没有可运行的项目级 npm audit / pip-audit / cargo audit 等依赖审计。不为此安装依赖或制造清单。已通过网络查询 OWASP、MDN、WHATWG,核对代码风险与防护方式。没有声称项目受某个 CVE 影响。
|
||||
|
||||
浏览器属于外部运行环境;其安装版本、扩展和系统漏洞不属于此项目依赖图,仍需保持浏览器更新。
|
||||
|
||||
## 2. 问题汇总
|
||||
|
||||
下表“位置”为修复后代码行;各项详情同时给出审核前行号,方便对应原问题。
|
||||
|
||||
| 编号 | 类别 | 严重级别 | 位置 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| S01 | 代码 | High | index.html:26、48 | 已修复;数据变化已获开发者同意 |
|
||||
| S02 | 代码 | Medium | index.html:31、48 | 已修复 |
|
||||
| S03 | 代码 | Medium | index.html:5、34 | 已修复;原行为是否触发请求取决于浏览器 |
|
||||
| S04 | 代码 | Low | index.html:41、47 | 已修复 |
|
||||
| S05 | 代码 | Low | index.html:62、87 | 建议关注 |
|
||||
| S06 | 代码 | Low | index.html:70、79 | 已修复 |
|
||||
|
||||
未发现 Critical 问题;未发现第三方依赖漏洞或真实硬编码凭据。静态审核和回归测试不能证明不存在所有漏洞。
|
||||
|
||||
## 3. 已修复问题与利用条件
|
||||
|
||||
### S01:CSV 公式注入(High,条件性风险)
|
||||
|
||||
- 原位置:`index.html:44–47`。标题仅转义双引号,未处理公式起始字符。
|
||||
- 条件:攻击者影响网站标题或提供恶意书签文件;用户转换后用会识别 CSV 公式的表格软件打开。是否发生外部请求、敏感数据读取或进一步危害取决于软件、公式功能及安全提示;不声称所有环境都能执行系统命令。
|
||||
- 修复:统一 `toCsvField()` 编码;对公式符号、相关全角符号、前置空白后的公式符号及开头控制字符添加可见 `文本:` 前缀。所有字段仍使用双引号包裹并转义内部双引号。
|
||||
- 取舍:开发者明确选择了“给危险标题加文本前缀”。正常标题保持原样;危险标题的底层 CSV 数据会变化。采用普通文字前缀避免依赖某个软件对单引号或制表符的特殊解释。
|
||||
- 参考:[OWASP CSV Injection](https://community.owasp.org/attacks/CSV_Injection)。OWASP 明确指出没有适用于所有表格软件和下游消费者的通用策略。
|
||||
- 验证:覆盖 `= + - @`、全角变体、前置空白、Tab、CR、LF、NUL 及实际转换输出。Excel / LibreOffice 的打开、重新保存、再次打开行为**待确认**,本次没有启动这些应用。
|
||||
|
||||
### S02:URL 字段未做完整 CSV 编码(Medium)
|
||||
|
||||
- 原位置:`index.html:45–47`。URL 直接拼接入带引号的字段;未统一转义内部引号。
|
||||
- 条件:构造浏览器 URL 序列化后仍包含特殊分隔内容的 href,并由下游 CSV 解析器处理。例如 `httpx:payload","=1+1` 会被原来的 http 前缀检查接受,而且浏览器 href 保留双引号;这与 S04 的宽松协议检查相关。普通 HTTP(S) URL 中的引号通常会被编码,不能把每个双引号 URL 都当成可利用案例。
|
||||
- 风险:CSV 结构损坏;在可突破字段边界的输入/解析器组合下可能插入额外字段或公式。
|
||||
- 修复:标题和经标准 URL 解析的 URL 都调用同一个 CSV 编码函数,采用 CRLF 行分隔,保留中文 BOM。
|
||||
- 验证:双引号、逗号、换行、HTML 实体、含引号 URL;浏览器确认上述自定义协议能保留引号,新实现拒绝该输入。浏览器将普通 HTTP(S) 测试 URL 中的引号编码为 `%22`,输出保持两个字段。
|
||||
|
||||
### S03:导入 HTML 的外部资源请求风险(Medium)
|
||||
|
||||
- 原位置:`index.html:36–37`。使用 DOMParser 解析不可信 HTML,没有资源加载策略。
|
||||
- 条件:用户选择包含 img、iframe 等资源标签的恶意 HTML;使用会在此解析过程中加载资源的浏览器。原始项目在特定浏览器是否发出请求**待确认**,本次未对修复前版本做动态复现。
|
||||
- 风险:向攻击者或本机/内网地址发起客户端请求,泄露 IP、访问时间等信息;不属于后端 SSRF,也没有证据表明原代码能读取跨源响应。
|
||||
- 修复:在未挂载的 template 内容中解析,只读取链接文本和 href,不将导入节点移入活动 DOM;增加 `default-src 'none'` 的 CSP,脚本和样式仅允许匹配 SHA-256 哈希的本地内联内容,禁用 base 和表单提交。删除内联 onclick,改为事件监听器,将内联状态样式移入 style 块。
|
||||
- 参考:[MDN DOMParser.parseFromString](https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString)、[WHATWG template 文档模型](https://html.spec.whatwg.org/dev/syntax.html)、[MDN CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy)。
|
||||
- 验证:恶意 img / iframe / stylesheet 资源均指向回归测试的本机计数端点;收到 0 次请求。导入 script 与事件处理器未执行,导入节点未进入页面。
|
||||
|
||||
### S04:协议校验使用字符串前缀(Low)
|
||||
|
||||
- 原位置:`index.html:46`。`startsWith('http')` 也允许 `httpx:` 等自定义协议。
|
||||
- 条件:恶意文件包含以 http 开头的自定义协议;下游软件打开链接,且系统安装了对应协议处理器。
|
||||
- 风险:非预期协议进入导出结果。转换器自身不会导航到这些链接,没有证据表明这构成直接代码执行。
|
||||
- 修复:使用无 base 参数的 `new URL()` 解析原始 href,只允许 `http:`、`https:`。不受导入 HTML 的 base 标签影响。非法 URL、相对 URL、file/data/javascript/自定义协议均跳过。
|
||||
- 验证:大小写 HTTP 接受;其他协议与相对 URL 拒绝。
|
||||
|
||||
### S06:未释放下载对象 URL(Low)
|
||||
|
||||
- 原位置:`index.html:54–60`。创建 Blob URL 后没有释放。
|
||||
- 条件:同一页面反复转换,尤其处理较大的书签文件。
|
||||
- 风险:累积保留 Blob 内存,可能造成页面资源压力。
|
||||
- 修复:下载锚点在 finally 中移除,随后延迟释放对象 URL,保留下载触发时间。
|
||||
- 验证:真实 FileReader 产生 Blob,下载文件名正确,URL 被释放。测试截获下载点击并检查 Blob 内容,没有验证操作系统实际保存文件的交互。
|
||||
|
||||
### 其他加固
|
||||
|
||||
- 状态输出使用 textContent。原来的 innerHTML 仅插入数字计数,没有证明原处存在 XSS;这是减少危险输出 API 的加固。
|
||||
- 读取失败给出用户可理解的状态提示,不输出文件内容或路径。
|
||||
- 将“100% 安全私密”的绝对声明改为准确的本地处理、不上传书签说明。没有在软件 UI 放入审核过程备注。
|
||||
|
||||
## 4. 密钥、敏感文件与其他检查
|
||||
|
||||
- 对全部当前文件进行了 API Key、AccessKey、Token、密码、私钥头、常见云/平台密钥模式、数据库连接串、Webhook 地址检索,并人工检查原始文件全部内容;未发现真实凭据。新增测试中的 `file:///secret` 是拒绝本地协议的测试数据,不是密钥或真实文件路径。
|
||||
- 未发现 `.env`、证书、私钥、配置、示例、文档或构建产物中的真实秘密。没有需要迁移到环境变量的配置,因此不创建空的 `.env.example`。
|
||||
- 当前目录不是 Git 仓库,没有 `.git` 或 `.gitignore`;无法确认历史版本、上级其他仓库、远程仓库或已发布副本曾否包含秘密。若以后加入环境文件,应忽略实际 `.env` 并只提交变量名示例。
|
||||
- 若开发者知道任何凭据曾提交或泄露,应到对应平台作废并重新生成;删除代码不足以消除泄露。
|
||||
- 未发现 SQL/NoSQL、命令执行、eval、动态代码加载、服务端模板、文件系统路径拼接、解压、反序列化、加密、TLS 关闭、敏感日志、跨窗口消息、凭据存储代码。
|
||||
- CSRF、服务端 SSRF、CORS、认证/越权、浏览器扩展权限、桌面 IPC/WebView、CI Action 固定版本检查无适用实现。
|
||||
- 允许用户选取 HTML 是功能输入;accept 属性仅是选择提示,不作为安全边界。导入内容不执行、不挂载、资源加载受策略限制。
|
||||
- HTTP(S) 书签仍可指向钓鱼网站;转换器不替用户审核目标网站信誉,也不自动访问目标网站。
|
||||
|
||||
## 5. 开发者决定与剩余风险
|
||||
|
||||
没有尚未获得批准、必须阻断本次修复的事项。公式前缀的数据变化已获同意;没有依赖大版本升级、删除功能、密钥轮换或旧数据结构兼容修改。
|
||||
|
||||
**S05:超大文件造成页面卡顿(Low,建议关注)**
|
||||
|
||||
- 位置:`index.html:62、87`,完整读取文件后在主线程解析,并在内存中生成 CSV。
|
||||
- 条件:用户主动选择极大或结构复杂的 HTML;风险主要是当前页面可用性。
|
||||
- 方案 A:设定明确的文件大小上限;方案 B:保留无限制输入,未来按实际规模考虑分块处理;方案 C:增加用户确认或可取消处理。
|
||||
- 建议:先依据真实书签文件大小确定上限。未擅自选取阈值,以免拒绝原本可转换的正常文件。这是后续产品取舍,本次保留原行为。
|
||||
|
||||
其他限制与建议:
|
||||
|
||||
1. 本次浏览器测试使用本机 Chrome,通过临时 HTTP 服务加载;直接 file:// 打开、Firefox、Safari 与实际 Excel/LibreOffice 打开 CSV 的结果**待确认**。没有扩大“已验证”的范围。
|
||||
2. CSP 哈希必须随内联脚本/样式更新;浏览器计算时使用规范化后的换行。新增测试会检查哈希是否匹配,后续改动需同步计算 SHA-256 哈希。
|
||||
3. 若未来通过服务器公开部署,应在 HTTP 响应增加合适的 CSP 和 `frame-ancestors` / 点击劫持防护;meta CSP 不支持所有响应头能力。目前没有服务器配置可修。
|
||||
4. CSP 和 template 防护依赖浏览器正确实现;保持浏览器更新,避免安装会读取页面和文件内容的不可信扩展。
|
||||
|
||||
## 6. 验证与改动文件
|
||||
|
||||
- 修改:`index.html`,完成上述修复与加固。
|
||||
- 新增:`security-tests.cjs`,仅使用 Node 内置模块和已安装 Chromium 浏览器,无新增第三方依赖。使用独立临时浏览器配置和本机服务器,不使用用户现有浏览器资料。
|
||||
- 新增:本报告,作为项目审核交付物,不在软件 UI 中展示。
|
||||
- 原项目没有构建或测试命令,因此不存在可运行的既有构建。执行 `node --check security-tests.cjs` 通过;执行 `node security-tests.cjs` 通过:**31 项浏览器检查、CSP 哈希一致、导入资源请求为 0**。
|
||||
- 首次沙箱内启动 Chrome/Edge 异常退出;在获得执行环境自动审批后使用沙箱外 Chrome 完成测试。测试期间发现并修正 CSP 哈希换行规范化问题,最终测试通过。
|
||||
|
||||
Conventional Commits 建议:
|
||||
|
||||
```text
|
||||
fix(security): harden bookmark parsing and CSV export
|
||||
```
|
||||
|
||||
未执行 Git 提交;当前目录也不是 Git 仓库。
|
||||
92
index.html
Normal file
92
index.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'sha256-GrbW/7V8Uz8EhUWpt5wG+cXD0+oSGGwhtN58oOLkO80='; style-src 'sha256-obdhBsAz/wb7TcBO6Wel5haALofvN0sAMw8qwcpWG8c='; base-uri 'none'; form-action 'none'">
|
||||
<title>本地安全书签转换器</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; text-align: center; background: #f9f9f9; }
|
||||
.box { background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
input { margin: 20px 0; }
|
||||
button { background: #4CAF50; color: white; border: none; padding: 10px 20px; font-size: 16px; border-radius: 4px; cursor: pointer; }
|
||||
button:hover { background: #45a049; }
|
||||
#status { margin-top: 15px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>本地离线书签转换器</h2>
|
||||
<p>所有处理均在您的电脑本地完成,<b>不会上传书签文件</b>。</p>
|
||||
<input type="file" id="fileInput" accept=".html"><br>
|
||||
<button id="convertButton" type="button">一键转换并下载 CSV</button>
|
||||
<p id="status" role="status"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toCsvField(value) {
|
||||
// A visible text prefix avoids spreadsheet formula interpretation.
|
||||
if (/^[\s\u0000-\u001f\u007f]*[=+@\-=+@-]/u.test(value) || /^[\u0000-\u001f\u007f]/u.test(value)) {
|
||||
value = `文本:${value}`;
|
||||
}
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function bookmarkHtmlToCsv(htmlContent) {
|
||||
// Template contents stay in an inert document and are never mounted.
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = htmlContent;
|
||||
const rows = ['\uFEFFTitle,URL'];
|
||||
let count = 0;
|
||||
template.content.querySelectorAll('a[href]').forEach(link => {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(link.getAttribute('href'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
||||
rows.push(`${toCsvField(link.textContent)},${toCsvField(url.href)}`);
|
||||
count++;
|
||||
});
|
||||
return { csvContent: rows.join('\r\n') + '\r\n', count };
|
||||
}
|
||||
|
||||
function convertBookmarks() {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const status = document.getElementById('status');
|
||||
if (fileInput.files.length === 0) {
|
||||
alert('请先选择从 Chrome 导出的 bookmarks.html 文件!');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = fileInput.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const htmlContent = e.target.result;
|
||||
const { csvContent, count } = bookmarkHtmlToCsv(htmlContent);
|
||||
|
||||
// 触发本地浏览器下载
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'bookmarks_output.csv';
|
||||
document.body.appendChild(a);
|
||||
try {
|
||||
a.click();
|
||||
} finally {
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
status.textContent = `🎉 转换成功!共提取了 ${count} 个书签,已自动下载 CSV 文件。`;
|
||||
};
|
||||
reader.onerror = () => {
|
||||
status.textContent = '无法读取文件,请重新选择文件后重试。';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
document.getElementById('convertButton').addEventListener('click', convertBookmarks);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
security-tests.cjs
Normal file
124
security-tests.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8').replace(/\r\n?/g, '\n');
|
||||
const sha256 = text => crypto.createHash('sha256').update(text).digest('base64');
|
||||
for (const tag of ['script', 'style']) {
|
||||
const content = html.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))[1];
|
||||
assert.ok(html.includes(`'sha256-${sha256(content)}'`), `${tag} CSP hash must match`);
|
||||
}
|
||||
const browser = process.env.SECURITY_TEST_BROWSER || [
|
||||
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||||
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
||||
].find(candidate => fs.existsSync(candidate));
|
||||
assert.ok(browser, 'Set SECURITY_TEST_BROWSER to a Chromium browser executable');
|
||||
|
||||
async function main() {
|
||||
let probes = 0;
|
||||
const harness = `
|
||||
(async () => {
|
||||
let checks = 0;
|
||||
const check = (condition, message) => {
|
||||
if (!condition) throw new Error(message);
|
||||
checks++;
|
||||
};
|
||||
try {
|
||||
for (const value of ['=1+1', '+1', '-1', '@SUM(1)', ' =1', '\\t=1', '\\r=1', '\\n=1', '\\u0000=1', '=1', '+1', '-1', '@SUM(1)']) {
|
||||
check(toCsvField(value).startsWith('"文本:'), 'Formula/control prefix: ' + JSON.stringify(value));
|
||||
}
|
||||
check(toCsvField('普通标题') === '"普通标题"', 'Normal title preserved');
|
||||
const opaqueLink = document.createElement('a');
|
||||
opaqueLink.setAttribute('href', 'httpx:payload","=1+1');
|
||||
check(opaqueLink.href.startsWith('http') && opaqueLink.href.includes('"'), 'Original prefix check could admit unescaped CSV quotes');
|
||||
check(toCsvField('a,"b"\\nc') === '"a,""b""\\nc"', 'CSV quoting');
|
||||
const ordinary = bookmarkHtmlToCsv('<!DOCTYPE NETSCAPE-Bookmark-file-1><HTML><META charset="UTF-8"><TITLE>Bookmarks</TITLE><H1>Bookmarks</H1><DL><p><DT><H3>文件夹</H3><DL><p><DT><A HREF="https://example.com/?q="x"">中文 & 标题</A><DT><A HREF="HTTP://example.org/">HTTP</A></DL><p></DL><p></HTML>');
|
||||
check(ordinary.count === 2, 'Netscape bookmark format');
|
||||
check(ordinary.csvContent.startsWith('\\uFEFFTitle,URL\\r\\n'), 'BOM and CRLF');
|
||||
check(ordinary.csvContent.includes('"中文 & 标题","https://example.com/?q=%22x%22"'), 'Entities and URL encoding');
|
||||
const hostile = bookmarkHtmlToCsv('<base href="https://example.com/"><a href="/relative">relative</a><a href="javascript:alert(1)">js</a><a href="data:text/html,x">data</a><a href="httpx://example.com">custom</a><a href="httpx:payload","=1+1">CSV breakout</a><a href="file:///secret">file</a><a href="//example.com">relative host</a><a href="https://example.com/">=1+1</a><a href="https://example.org/?x=",=1+1">quote</a>');
|
||||
check(hostile.count === 2, 'Only absolute HTTP(S) accepted; base ignored');
|
||||
check(hostile.csvContent.includes('"文本:=1+1"'), 'Formula escaped in actual output');
|
||||
check(hostile.csvContent.includes('%22,=1+1'), 'URL cannot break out of CSV field');
|
||||
window.securityPayloadExecuted = false;
|
||||
const origin = location.origin;
|
||||
bookmarkHtmlToCsv('<img src="' + origin + '/probe-image" onerror="window.securityPayloadExecuted=true"><iframe src="' + origin + '/probe-frame"></iframe><link rel="stylesheet" href="' + origin + '/probe-css"><script>window.securityPayloadExecuted=true;<\\/script><a href="https://example.com/"><b>Safe</b></a>');
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
check(!window.securityPayloadExecuted, 'Imported scripts and event handlers stay inert');
|
||||
check(!document.querySelector('iframe, img, link'), 'Imported nodes never mounted');
|
||||
const button = document.getElementById('convertButton');
|
||||
check(typeof convertBookmarks === 'function', 'Application script allowed by CSP');
|
||||
let alertCount = 0;
|
||||
window.alert = () => alertCount++;
|
||||
button.click();
|
||||
check(alertCount === 1, 'Button handler and empty-file validation');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File(['<a href="https://example.com/">书签</a>'], 'bookmarks.html', { type: 'text/html' }));
|
||||
fileInput.files = transfer.files;
|
||||
let exportedBlob;
|
||||
let revoked = false;
|
||||
const originalCreate = URL.createObjectURL;
|
||||
const originalRevoke = URL.revokeObjectURL;
|
||||
URL.createObjectURL = blob => { exportedBlob = blob; return originalCreate(blob); };
|
||||
URL.revokeObjectURL = url => { revoked = true; originalRevoke(url); };
|
||||
let downloadName;
|
||||
HTMLAnchorElement.prototype.click = function () { downloadName = this.download; };
|
||||
button.click();
|
||||
for (let i = 0; i < 50 && !exportedBlob; i++) await new Promise(resolve => setTimeout(resolve, 20));
|
||||
check(!!exportedBlob, 'Real FileReader conversion produces Blob');
|
||||
check(downloadName === 'bookmarks_output.csv', 'Download filename');
|
||||
check((await exportedBlob.text()).includes('"书签","https://example.com/"'), 'Downloaded content');
|
||||
check(document.getElementById('status').textContent.includes('1 个书签'), 'Status uses extracted count');
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
check(revoked, 'Blob URL released');
|
||||
document.body.dataset.securityResult = JSON.stringify({ ok: true, checks });
|
||||
} catch (error) {
|
||||
document.body.dataset.securityResult = JSON.stringify({ ok: false, message: error.message, checks });
|
||||
}
|
||||
})();`;
|
||||
const testPage = html
|
||||
.replace("script-src ", `script-src 'sha256-${sha256(harness)}' `)
|
||||
.replace('</body>', `<script>${harness}</script></body>`);
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url.startsWith('/probe')) probes++;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(req.url === '/test' ? testPage : '');
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'bookmark-security-'));
|
||||
try {
|
||||
const child = spawn(browser, [
|
||||
'--headless', '--disable-gpu', '--no-first-run', '--no-default-browser-check',
|
||||
'--disable-background-networking', `--user-data-dir=${profile}`,
|
||||
'--virtual-time-budget=5000', '--dump-dom',
|
||||
`http://127.0.0.1:${server.address().port}/test`,
|
||||
]);
|
||||
let output = '';
|
||||
let errors = '';
|
||||
child.stdout.on('data', chunk => output += chunk);
|
||||
child.stderr.on('data', chunk => errors += chunk);
|
||||
const timeout = setTimeout(() => child.kill(), 30000);
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
assert.equal(code, 0, errors);
|
||||
const resultMatch = output.match(/data-security-result="([^"]*)"/);
|
||||
assert.ok(resultMatch, 'Browser harness completed: ' + errors.slice(-1000));
|
||||
const result = JSON.parse(resultMatch[1].replace(/"/g, '"').replace(/&/g, '&'));
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
assert.equal(probes, 0, 'Imported HTML must not send resource requests');
|
||||
console.log(`PASS: ${result.checks} browser checks, CSP hashes match, zero imported resource requests.`);
|
||||
} finally {
|
||||
server.close();
|
||||
// Only remove the temporary browser profile created by this test.
|
||||
assert.ok(profile.startsWith(path.join(os.tmpdir(), 'bookmark-security-')));
|
||||
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
}
|
||||
}
|
||||
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user