// 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 上常写 ``,
* 浏览器对 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;