/* @vitest-environment jsdom */ // shared/render-sanitize.js 规则测试 // // 覆盖: // 1. XSS 防护:'); expect(html).not.toMatch(/)'); expect(html).not.toMatch(/href="data:text\/html/i); }); it(' 标签被剥离', () => { const html = render(''); expect(html).not.toMatch(/ action 被剥离', () => { const html = render(''); expect(html).not.toMatch(/action="javascript:/i); }); it('//evil.com/x.png(协议相对 URL)在 被剥离', () => { // 之前 ALLOWED_URI_REGEXP 的 `[^a-z]` 分支匹配任何非字母字符, // 放过 `//evil.com/x.png`。浏览器解析为「继承当前 scheme」,可静默 // 导航到外站(或在 renderer 上下文里利用 about:blank 之类)。 const html = render('[click](//evil.com/x.png)'); expect(html).not.toMatch(/href="[^"]*evil\.com/i); }); it('//evil.com/x.png 在 被剥(不依赖 DOMPurify 内置)', () => { const html = render(''); expect(html).not.toMatch(/src="[^"]*evil\.com/i); }); }); describe(' 保留策略', () => { it.each([ ['相对路径 img/a.png', 'img/a.png'], ['相对 ./other.md', './other.md'], ['绝对 /abs/x.png', '/abs/x.png'], ['file:/// 协议', 'file:///C:/x.png'], ['data:image/png base64', 'data:image/png;base64,AAA'], ['https://', 'https://e/x.png'], ])('%s 保留 src', (_label, src) => { const html = render(``); expect(html).toContain(`src="${src}"`); }); it('javascript: 在 被剥(hook 主动剥,不依赖 DOMPurify 内置)', () => { // 现在所有 src/href/action 都走 DANGEROUS_URI_REGEXP 主动剥 —— 不再依赖 // DOMPurify v3 内置兜底(虽然 v3 仍会兜底剥 javascript:,但 hook 是第一道闸)。 const html = render(')'); expect(html).not.toMatch(/src="javascript:/); }); it('vbscript: 在 被剥', () => { // 旧实现对 vbscript: 放行(keepAttr=true)—— DOMPurify v3 不兜底这个。 // 现在由 hook 主动剥,与 javascript: 行为一致。 const html = render(')'); expect(html).not.toMatch(/src="vbscript:/i); }); it('about:blank 在 被剥(policy 统一:未在白名单的 scheme 一律不放)', () => { // 旧实现会放行 about:blank(不是危险协议但也不在白名单);现在统一被剥。 const html = render(''); expect(html).not.toMatch(/src="about:/i); }); it('data:text/html 在 被剥(统一策略:仅 data:image/* 放行)', () => { // 之前测试断言「保留」(理由是浏览器不把 img 渲染为 HTML)—— 但跨浏览器 / 跨版本 // 一致性差,且与 ALLOWED_URI_REGEXP 单点策略不一致。现在统一被剥。 const html = render(''); expect(html).not.toMatch(/src="data:text\/html/i); }); // fix(audit 2026-08):SVG 数据 URL 在 上点击会导航到 top-level SVG // 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。 // ALLOWED_URI_REGEXP 限定 data:image 为栅格格式(png/jpeg/gif/webp/...), // 显式拒绝 svg+xml —— Markdown 几乎不会内嵌 SVG,raster 已覆盖 99% 场景。 it('data:image/svg+xml 在 被剥(栅格白名单显式排除 SVG)', () => { const html = render(''); expect(html).not.toMatch(/src="data:image\/svg\+xml/i); }); // audit fix (Round 13 / Sec-H2):file:// 必须严格 file:///... 三斜杠。 // 拒绝 file://host/... (Windows 上解析为 \\host\share,触发 SMB NTLMv2 认证, // 攻击者能抓受害者的 domain\user + NTLM hash —— CVE-2023-23397 类原语)。 it.each([ ['远程主机 evil.com(CVE-2023-23397 原语)', 'file://evil.com/share/pixel.png'], ['UNC 形式 ////', 'file:////evilhost/share/x.png'], ['显式 localhost(也应拒绝 — 唯一合法形式只有 ///)', 'file://localhost/C:/x.png'], ['大小写混合 UNC', 'FILE:////evilhost/x.png'], ['前导空格 + 远程主机(绕过 trim)', ' file://evil.com/x.png'], ])('file:// %s 在 被剥', (_label, src) => { const html = render(``); expect(html).not.toMatch(/src="file:/i); }); it('file:/// 三斜杠(无 host)在 仍保留(合法本地图片)', () => { const html = render(''); expect(html).toContain('src="file:///C:/Users/me/Notes/x.png"'); }); // audit fix (Round 13 / Sec-M):项目里 markdown 保留合法 inline style(color / // font-size / background:url(https://...) 等纯视觉属性),由 isDangerousStyleValue // 按 CSS 属性名做 denylist 拦截。denylist 覆盖了所有能改变布局 / 跳出文档流 / // 遮挡 UI / 隐藏元素 / 让用户看不清真实界面的属性 —— 即便攻击者把整个浏览器 // 窗口当画布也办不到。 // // 这些测试走 renderHtml() 而不是 render() —— marked 的 lexer 会对裸 inline // HTML 做容错(部分 inline 会按字符实体 / 段落拆分),无法精确验证 DOMPurify // 在 ALLOWED 名单下的剔除策略。绕开 marked 直喂 HTML 给 sanitize,行为更可预测。 it.each([ ['position:fixed 全屏覆盖层', 'x'], ['display:none(不让用户看到真实 UI)', 'hidden'], ['opacity:0(透明覆盖层)', 'overlay'], ['visibility:hidden', 'x'], ['pointer-events:none(透传点击到下层)', 'trap'], ['transform(绕过父级 contain)', 'off-screen'], ['content(注入伪元素文本)', 'x'], ['top/left/right/bottom/inset(绝对定位)', 'x'], ])('style 含危险属性 %s → style 属性被剥', (_label, htmlIn) => { const html = renderHtml(htmlIn); expect(html).not.toMatch(/style=/i); }); it('合法 style(color / font-size / text-align)保留', () => { // 视觉自定义应通过合法 CSS 属性而非 inline style —— 但项目历史包袱允许 // 一些纯样式 style。回归测试,确保 denylist 没误伤。 const html = renderHtml('x'); expect(html).toMatch(/color\s*:\s*red/i); expect(html).toMatch(/font-size\s*:\s*14px/i); }); it(' 标签被剥(防 SVG-namespaced '); expect(html).not.toMatch(// 被剥', () => { const html = renderHtml(''); expect(html).not.toMatch(/ { const html = render(''); expect(html).toMatch(/src="data:image\/png;base64,iVBORw0KGgo="/); }); }); describe('非 IMG 标签的 file: 协议', () => { it(' 被剥', () => { const html = render('[l](file:///C:/x.txt)'); expect(html).not.toMatch(/href="file:\/\/\/C:\/x\.txt/); }); it(' action 被剥', () => { const html = render(''); expect(html).not.toMatch(/action="file:\/\/\/C:\/x/); }); // audit fix (Round 4 P1-5):非 IMG 标签的 URI 属性也走 ALLOWED 校验剥 // data:image/svg+xml。ALLOWED_URI_REGEXP 显式排除 svg+xml(只允许栅格), // 之前非 IMG 只走 DANGEROUS 负向预查放过 data:image/svg+xml → 完全依赖 // DOMPurify v3 内置兜底。现在 hook 第二道闸显式剥。 it(' 被剥(非 IMG 也走 ALLOWED)', () => { const html = render('[l](data:image/svg+xml;base64,PHN2Zz4=)'); expect(html).not.toMatch(/href="data:image\/svg\+xml/i); }); it(' 仍保留(普通 https 不被误剥)', () => { const html = render('[l](https://example.com)'); expect(html).toMatch(/href="https:\/\/example\.com"/); }); it('target=_blank 自动注入的 rel="noopener noreferrer" 不被误剥', () => { // 之前 bug:non-IMG ALLOWED 检查对所有非 IMG 属性生效,把 DOMPurify 自动 // 加的安全 rel="noopener noreferrer" 也判定为不通过 ALLOWED → 误剥。 // 现在限定为 URI 类属性(href/src/action 等),安全属性放过。 const html = render('[x](https://e)'); expect(html).toMatch(/rel="noopener noreferrer"/); }); }); describe(' data:image 大小上限 (audit Sec-M3)', () => { it('正常大小 data:image/png base64 保留', () => { const html = render(''); expect(html).toMatch(/src="data:image\/png;base64,AAAA"/); }); it('巨大 data:image (>10MB) 被剥 src', () => { // 绕过 marked 直接 sanitize:marked 的 lexer 在 11MB base64 上会先栈溢出, // 走 marked 路径反而测不到我们要保护的那一层。这里手搓 直接喂给 // DOMPurify —— 验证 hook 在 data:image 巨长 base64 上会主动 keepAttr=false。 const huge = `data:image/png;base64,${'A'.repeat(11 * 1024 * 1024)}`; const html = DOMPurifyInstance.sanitize(``, { ADD_ATTR: ['src'], ALLOWED_URI_REGEXP, }); expect(html).not.toMatch(new RegExp(`src="data:image/png;base64,${'A'.repeat(100)}`)); }); it('10MB 临界值正好放过(不挡合理大小)', () => { // 边界:刚好等于上限应当放过;实际我们想留余量,写测试时只看 < 1MB const small = `data:image/png;base64,${'A'.repeat(1024 * 1024)}`; const html = render(``); expect(html).toMatch(/src="data:image\/png;base64,A{100}/); }); it('非 data: 的超长 URL 不受影响(绝对 file:/https: 走原路径)', () => { // file:/// 与 https:// 不应被本次 size 限制波及;行为与先前一致。 const longHttps = `https://e.com/${'a'.repeat(1024 * 1024)}.png`; const html = render(``); expect(html).toMatch(/^x'); expect(html).not.toMatch(/url\s*\(\s*javascript:/i); // 整条 style 被剥(保守策略) expect(html).not.toMatch(/style=/i); }); it('style 里 url("javascript:...") 引号包裹也被剥', () => { const html = render('x'); expect(html).not.toMatch(/javascript:/i); }); it('style 里 url(VBScript:) 大小写不敏感也被剥', () => { const html = render('x'); expect(html).not.toMatch(/vbscript:/i); }); it('style 里 url(data:text/html...) 被剥(拒绝非 image data:)', () => { const html = render('x'); expect(html).not.toMatch(/data:text\/html/i); }); it('style 里 expression(alert(1)) legacy IE 模式被剥', () => { const html = render('x'); expect(html).not.toMatch(/expression\s*\(/i); }); it('style 里 behavior:url(...) legacy IE HTC 模式被剥', () => { const html = render('x'); expect(html).not.toMatch(/behavior\s*:/i); }); it('style 里 -moz-binding:url(...) legacy Mozilla XBL 模式被剥', () => { const html = render('x'); expect(html).not.toMatch(/-moz-binding/i); }); it('style 里 @import 外链资源加载被剥', () => { const html = render('x'); expect(html).not.toMatch(/@import/i); }); it('合法 style 保留(color / font-size 等无害属性不动)', () => { const html = render('x'); expect(html).toMatch(/style="[^"]*color\s*:\s*red/i); expect(html).toMatch(/style="[^"]*font-size\s*:\s*14px/i); }); it('合法 url()(http: / data:image/)保留', () => { const html = render('x'); expect(html).toMatch(/style="[^"]*color\s*:\s*red/i); // 字符串里出现的 url(https://...) 是合法上下文不应剥 expect(html).not.toMatch(/style="[^"]*javascript:|vbscript:|expression\s*\(|@import/i); }); }); describe('marked 输出增强', () => { it('链接注入 target=_blank + rel="noopener noreferrer"', () => { const html = render('[x](https://e)'); expect(html).toMatch(/target="_blank"/); expect(html).toMatch(/rel="noopener noreferrer"/); }); it('中文标题生成 id 锚点', () => { const html = render('## 你好世界'); expect(html).toMatch(//); }); it('同名标题生成 -1/-2 后缀', () => { const html = render('## 你好世界\n\n## 你好世界'); expect(html).toMatch(/id="你好世界"/); expect(html).toMatch(/id="你好世界-1"/); }); });
x