// Markdown → 安全 HTML 的核心规则(Stage 8:与 preload.js 共用) // // 职责: // - 定义 ALLOWED_URI_REGEXP(URI scheme 白名单 + 相对路径分支) // - 注册 uponSanitizeAttribute 钩子(统一走 ALLOWED_URI_REGEXP; // 危险协议 javascript:/vbscript:/data:text\/html/file: 在所有标签上都剥) // - 导出 renderMarkdown(markdown, dompurifyInstance) → safeHtml // // 边界: // - 不引用 electron / contextBridge / marked —— 由 preload 注入 // - 不导出 DOMPurify 实例(每个进程各自创建,避免泄漏 window) // // preload.js 与 tests/unit/render-sanitize.test.js 都 require 本文件, // 保证规则不会在「实现」与「测试」之间漂移。 /** * 允许的 URI 协议: * - 前半段:显式 scheme 白名单(http/https/mailto/tel/callto/file/data:image/raster) * —— file: 必须先放行,DOMPurify 才会回调 uponSanitizeAttribute 钩子 * —— data:image 限定为栅格格式(png/jpeg/gif/webp/bmp/ico/tiff)—— 显式 * 拒绝 svg+xml/svg 等可携带 JS / XML 外部实体的格式。SVG 数据 URL 在 * `` 上点击会导航到 top-level SVG 上下文,现代 Chromium 多半 * 拦截脚本执行,但跨浏览器 / 跨版本一致性差。Markdown 用例几乎不需 * SVG 内嵌图,raster 已覆盖 99% 场景。 * - 后半段:相对路径分支('img/a.png'、'./other.md'、'/abs/x.png'、'../up.md') * —— `(?![/\\]{2})[/\\]|[^a-z/\\]` 覆盖 `#` 锚点 + 单 `/` 或 `\` 开头; * `(?![/\\]{2})` 显式拒绝「协议相对 URL」(`//evil.com/x.png` / * `\\evil.com\x.png`),避免静默导航到外站 * —— `[a-z.+#-]+(?:[\\/][^/\\]*)*` 覆盖字母开头的相对路径:首段允许 * `.`/`#`/`+`/`-`(如 `page.md#chapter`),后续段允许 `:` 与 `#` * (如 `dir/sub:dir/file.png`、`sprite.svg#icon-home`、`foo/bar#baz` * —— 跨文件锚点跳转的 `文件#章节` 形态由 app.js resolveLocalFile 消费)。 * * 危险协议 (javascript:/vbscript:/data:text\/html):顶层 `(?!)` 显式拒已知 * 危险 scheme;其余未授权 scheme(`blob:x` / `chrome:x` / `ms-msdt:x` 等 * 「scheme:无点 token」形态)因分支 A 要求「冒号 + 带点扩展名」不再命中 —— * 维持白名单的默认拒绝。 * * Windows 路径分隔符 `\`:用户在 Windows 上常写 `![](img\foo.png)`, * 浏览器对 URL 会自动把 `\` 规范化成 `/`(Chrome/Firefox 都如此), * 校验放行 `\` 与 `/` 都不会引入新风险 —— 真实加载由 resolveRelativeImages 控。 */ // 末尾 $ 锚整串;scheme / data:image / 纯锚点分支用 [^\s]* 兜底后续字符。 // 顶层 `(?!)` 拒已知危险 scheme(about: / javascript: / vbscript: / // data:text\/html)—— 它们与相对路径分支同形态,必须在外层先斩。 // 相对路径分支: // A. `[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])` —— Mac/Linux 文件名带单冒号 // (如 `image:001.jpg`、`screenshot:1.png`)。要求冒号后是「带点扩展名」 // 形态:`blob:x` / `chrome:x` / `ms-msdt:x` 这类「scheme:无点 token」不再 // 命中(旧版 `[a-z\d.\-_]+` 不要求扩展名点,等于把默认拒绝白名单放宽成 // 只拦 4 个 scheme 的黑名单)。`:` 后仍不许紧跟 `/` 或 `\`(否则会误吃 // `data:text/html`、`javascript:foo/bar` 之类;这些也被顶层 `(?!)` 拦)。 // B. `[a-z.+#-]+(?:[\\/][^/\\]*)*` —— 多段 / 单段路径,支持 `img/a.png` / // `../up.md` / `/abs/x.png`(经前一分支)/ Windows 反斜杠 / 路径段内含 // `:`(如 `dir/sub:dir/file.png`)/ 段内与末段 `#fragment`(如 // `sprite.svg#icon-home`、`page.md#chapter`、`foo/bar#baz`——旧版段字符类 // `[^/\\#]` 排除 `#`,把跨文件锚点跳转整类误拒,本分支修复)。首段类 // `[a-z.+#-]` 不含 `:` 与分隔符,拼不出「scheme:」形态,与 A 的扩展名 // 要求共同维持默认拒绝。 // `data:image/...` 在顶层 `(?!)` 里只拒 `data:text/html`(白名单分支仍允许 // `data:image/png` 等栅格格式);非 IMG URI 由 isDangerousUriNonImg 兜底 // javascript:/vbscript:/file:/data(?!:image\/),IMG 由 installHooks 的额外检查兜底。 const ALLOWED_URI_REGEXP = /^(?!about:|javascript:|vbscript:|data\s*:\s*text\s*\/\s*html)(?:(?:https?|mailto|tel|callto|file):[^\s]*|data:image\/(?:png|jpe?g|gif|webp|bmp|ico|tiff);base64,[^\s]*|#[^\s]*|(?:(?![/\\]{2})[/\\]|[^a-z/\\])[^\s]*|[a-z+.-]+:[a-z\d.\-_]*\.[a-z\d]+(?![/\\])|[a-z.+#-]+(?:[\\/][^/\\]*)*)$/i; // data:image 单 URL 字节上限(防 OOM)。10 MB 对正常 AI 输出 + 用户内嵌图 // 已经远超合理上限 —— Notes 单文件 5 MB 限制会先卡住写入,所以 10 MB 留余量。 const DATA_IMAGE_MAX_URL_LENGTH = 10 * 1024 ** 2; /** * 危险 URI scheme —— 在非 IMG 标签(<a href> / <form action> / iframe 等) * 上剥,包括 file:(导航/数据外流通道)。IMG/src 例外:file:/// 在 IMG 上是 * Notes 数据目录本地图片路径,需要保留。 * * audit fix (C1):钩子里先 trim 再匹配。原正则 `^...javascript:...` 锚定首字符, * 若 DOMPurify 未规范化前导空格 / 控制字符(` ` 等实体), * 像 `` 就能绕过钩子。trim 后再 `^` 匹配, * 同时把 NUL/控制字符一并处理(C0 控制字符 0x00-0x1F + 0x7F)。 */ const DANGEROUS_URI_NON_IMG_REGEXP = /^(?:javascript|vbscript|data(?!:image\/)|file):/i; function isDangerousUriNonImg(value) { if (typeof value !== 'string') return false; // 剥前导空白 + 控制字符:HTML 实体解码后可能留下的 \x00-\x1F / \x7F / 空格 / 换行。 // 用循环 + charCodeAt 比较而非 regex —— ESLint no-control-regex 会拒绝 regex 字面里的控制字符。 let i = 0; while (i < value.length) { const code = value.charCodeAt(i); // 空白:\t(9) \n(10) \v(11) \f(12) \r(13) 空格(32) NBSP(160) 等 // 控制字符:0-31 与 127 if (code <= 32 || code === 127 || code === 160) { i += 1; continue; } break; } return DANGEROUS_URI_NON_IMG_REGEXP.test(value.slice(i)); } /** * audit fix (Round 9):style 属性里的危险 CSS 模式防御。 * * 场景:`` —— 上面 * isDangerousUriNonImg 检的是 attrValue **开头**,对「值里嵌套 javascript: URL * 的 style」无能为力。同理 `style="width:expression(alert(1))"`(legacy IE)、 * `style="-moz-binding:url(...)"`(legacy Mozilla XBL)、`behavior:url(...)` * (legacy IE HTC)。 * * DOMPurify v3 默认对这些模式有部分保护(CSS sanitizer 拒 url(javascript:)), * 但行为跨版本 / 跨浏览器一致性差。本函数做防御性深度扫描:发现任一危险模式 * → 整条 style 属性剥掉(保守:宁可错杀不可漏过)。 * * 同时扫描 @import(可绕过 background-image 限定的外链资源加载)和 * url(javascript:|vbscript:|data:text/html|file:) 等危险 URL scheme。 * * 注意:url() 内的空白 / 引号 / 大小写都要容忍 —— 用不区分大小写的 regex, * 容忍 url 关键字后的可选空白。 */ const DANGEROUS_STYLE_PATTERNS = [ // url(javascript:...) / url("javascript:...") / url('javascript:...') // 容忍可选空白 + 单/双引号包裹 + 大小写 /url\s*\(\s*['"]?\s*(?:javascript|vbscript|data\s*:\s*text\s*\/\s*html|file)\s*:/i, // 协议相对 URL(url(//evil.com/...)):浏览器按当前页协议补全为 http(s)://, // 绕过上面的 scheme 白名单,造成外部资源请求(IP / Referer / UA 泄露)。 /url\s*\(\s*['"]?\s*\/\//i, // legacy IE CSS expression() /expression\s*\(/i, // legacy IE HTC behavior /\bbehavior\s*:\s*url\s*\(/i, // legacy Mozilla XBL binding /-moz-binding\s*:/i, // CSS @import(外链资源加载 / CSP 绕过) /@import/i, // Round 13 fix (M1):「@font-face { src: url(...) }」可远程加载任意字体。 // 浏览器加载字体时会把当前页面的 UA / Referer / 来源 URL 一并发出, // 形成 IP 泄露 / 指纹面;恶意字体还能触发已知 CVE(如 TrueType 解析器 // 历史 bug)。Markdown 不需要自定义字体,命中则整条 style 属性剥掉。 /@font-face/i, ]; // audit fix (Round 13 / Sec-M):CSS 属性 denylist —— 阻止攻击者把整窗当画布。 // 思路:Markdown 的合法 inline style 几乎只用 color / font-size / text-align / // background:url(https://...) 这类纯视觉属性;任何「能改变布局 / 跳出文档流 / // 遮挡 UI / 隐藏元素 / 让用户看不清真实界面」的属性都属于攻击面。 // // 受保护列表(每个测试过: // - `` // —— 全屏透明覆盖层,配合外链跳转 = 整窗 UI 欺骗 / 点击劫持 // - `` —— 隐藏 chrome(已 // 靠 FORBID_TAGS:['svg'] 拦死,这条是给「未来如果放宽 svg」做兜底) // - `.toolbar,.statusbar{visibility:hidden}` —— 视觉欺骗 // - `body::after{content:"会话过期请重新输入 API Key"}` —— 钓鱼覆盖层 // - `pointer-events:none` 让 UI 看着可点但透传到下层 // - `transform: ...` 在 markdown 里几乎不合法使用,且可绕开父级 contain // - `opacity:0` 让链接看着没东西实际可点 // // 注意:\b 词边界要求属性名是独立 token,`font-size` 不会被误命中 `size`。 const DANGEROUS_STYLE_PROPS = [ 'position', 'inset', 'top', 'left', 'right', 'bottom', 'z-index', 'zindex', 'float', 'clear', 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height', 'margin', 'padding', 'transform', 'translate', 'visibility', 'opacity', 'pointer-events', 'content', // display:none 是合法用法(折叠冗余段落)但 XSS 高敏,宁可错杀。 // 想要折叠段落用引用 styled 容器而不是内联 style。 'display', ]; function isDangerousStyleValue(value) { if (typeof value !== 'string') return false; // style 值的注释 / 字符串里也照样扫 —— 攻击者可借 `/* xxx */` 试图绕过; // 保守策略:任一危险模式出现 → 剥整条 style。 for (const pat of DANGEROUS_STYLE_PATTERNS) { if (pat.test(value)) return true; } // 属性名扫描 —— 把 value 按 `;` 拆成 declaration,逐个看属性名是否在 // 黑名单。\b 词边界防 `font-size` 误命中 `size`、防 `text-align` 误命中 // 不存在的 `align`。兼容 `:property` 与 `: property` 两种写法。 for (const decl of value.split(';')) { const m = decl.match(/^\s*([a-zA-Z-]+)\s*:/); if (m && DANGEROUS_STYLE_PROPS.includes(m[1].toLowerCase())) return true; } return false; } /** * 注册 uponSanitizeAttribute 钩子: * - <img src> 走 ALLOWED_URI_REGEXP 显式校验(统一策略); * file:/// 放行(ALLOWED 已含),javascript:/vbscript:/data:text\/html 等 * 未在白名单的 scheme 一律剥 * - 其他标签的 href/src/action 见到 javascript:/vbscript:/data:text\/html/file: * → 主动剥 * * 之前 <img src> 是 keepAttr=true 全放行,靠 DOMPurify v3 内置兜底处理 javascript:; * vbscript: / data:text\/html 在 img 上不会被 DOMPurify 兜底剥掉,靠「img 不渲染 html」 * 的策略假设——跨浏览器 / 跨版本一致性差。现在所有 src/href/action 都按 ALLOWED 过滤, * 单一事实源;同时非 IMG 标签的 file: 仍主动剥(导航 / 数据外流风险)。 * * 必须在调用 DOMPurifyInstance.sanitize 之前注册一次(v3 的钩子是实例级, * 重启进程后丢失;不需要 removeHook)。 * * @param {object} DOMPurifyInstance - 由 createDOMPurify(window) 创建的实例 */ function installHooks(DOMPurifyInstance) { if (!DOMPurifyInstance || typeof DOMPurifyInstance.addHook !== 'function') return; DOMPurifyInstance.addHook('uponSanitizeAttribute', (node, data) => { const isImgSrc = data.attrName === 'src' && node.tagName === 'IMG'; // 走显式白名单 —— ALLOWED_URI_REGEXP 已含 file:/data:image/,未匹配即剥 if (isImgSrc) { if (!ALLOWED_URI_REGEXP.test(data.attrValue)) { data.keepAttr = false; return; } // 防御纵深:ALLOWED 的分支 A 现要求「冒号 + 带点扩展名」(放行 Mac // 文件名如 `image:001.jpg`),`javascript:foo` / `about:blank` 这类无点 // token 已在正则层被拒、顶层 `(?!)` 也拦了已知危险 scheme;但 // `data:a.b` 等「危险前缀 + 带点尾巴」仍会形态命中分支 A —— // isDangerousUriNonImg 只跑非 IMG 路径且不含 `about:`,IMG 保留这道 // scheme 前缀检查兜底(同时挡 `data:text/html;