From e6912acbb7e7487bb20748cffb6e57850a01c6ac Mon Sep 17 00:00:00 2001 From: "Julian F." Date: Wed, 14 Oct 2026 09:05:20 -0400 Subject: [PATCH] fix(security): harden bookmark parsing and CSV export --- SECURITY_AUDIT.md | 129 +++++++++++++++++++++++++++++++++++++++++++++ index.html | 92 ++++++++++++++++++++++++++++++++ security-tests.cjs | 124 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 SECURITY_AUDIT.md create mode 100644 index.html create mode 100644 security-tests.cjs diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..c86fa8d --- /dev/null +++ b/SECURITY_AUDIT.md @@ -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 仓库。 diff --git a/index.html b/index.html new file mode 100644 index 0000000..0fe363c --- /dev/null +++ b/index.html @@ -0,0 +1,92 @@ + + + + + + 本地安全书签转换器 + + + +
+

本地离线书签转换器

+

所有处理均在您的电脑本地完成,不会上传书签文件。

+
+ +

+
+ + + + diff --git a/security-tests.cjs b/security-tests.cjs new file mode 100644 index 0000000..7efed05 --- /dev/null +++ b/security-tests.cjs @@ -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]*?)`))[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('Bookmarks

Bookmarks

文件夹

中文 & 标题
HTTP

'); + 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('relativejsdatacustomCSV breakoutfilerelative host=1+1quote'); + 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('`); + 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; });