This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

View File

@@ -0,0 +1,415 @@
/* @vitest-environment jsdom */
// shared/render-sanitize.js 规则测试
//
// 覆盖:
// 1. XSS 防护:<script> / 事件属性 / javascript: / vbscript: / data:text\/html / iframe
// 2. <img src> 保留策略相对路径、绝对路径、file:、data:image/、https:;危险协议被剥
// 3. 非 IMG 标签剥 file:a / form / iframe
// 4. ALLOWED_URI_REGEXP 单测:覆盖各 scheme / 相对路径 / 危险协议
//
// 这些规则也用于 preload.js 的 renderMarkdown —— 测试与实现共享
// shared/render-sanitize.js禁止规则在两侧漂移。
import { describe, it, expect, beforeAll } from 'vitest';
import { JSDOM } from 'jsdom';
import createDOMPurify from 'dompurify';
import { marked } from 'marked';
import { ALLOWED_URI_REGEXP, installHooks } from '../../shared/render-sanitize.js';
// audit fix直接 import shared/slug.js不再源码级镜像 heading slug 算法。
// 之前在 beforeAll + render() 里复制了两份 slugify 实现 —— 与 shared/slug.js 漂移
// 时测试还能过但真实场景preload 走 shared/slug.js已经对不上。
import { slugifyHeading } from '../../shared/slug.js';
let dom;
let DOMPurifyInstance;
beforeAll(() => {
dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
DOMPurifyInstance = createDOMPurify(dom.window);
installHooks(DOMPurifyInstance);
// 复制 preload 的 marked 配置target=_blank + heading id slug
// 这里的 slug 算法不再手写 —— 改走 shared/slug.js#slugifyHeading
// 与 preload / outline 三处保持唯一事实源。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
marked.setOptions({ gfm: true, breaks: false, renderer: mdRenderer });
});
function render(md) {
// 重建 renderer + 每次独立的 seenSlugs与 preload renderMarkdown 行为一致):
// heading id 的去重计数按文档重置,跨文档不串。
const mdRenderer = new marked.Renderer();
const baseLink = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (h, t, txt) => baseLink(h, t, txt).replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
const seenSlugs = new Set();
mdRenderer.heading = (text, level, raw) => `<h${level} id="${slugifyHeading(raw, seenSlugs)}">${text}</h${level}>\n`;
const rawHtml = marked.parse(md, { renderer: mdRenderer });
return DOMPurifyInstance.sanitize(rawHtml, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
/**
* 绕开 marked直接喂 HTML 给 DOMPurify —— 模拟攻击者把恶意 HTML 塞进 markdown
* 文档边缘marked 解析时会吞掉大部分 inline HTML但裸 inline / 嵌入已是 HTML 的
* 子树仍会透传)。这里与 preload 真实 sanitize 配置严格对齐。
*/
function renderHtml(html) {
return DOMPurifyInstance.sanitize(html, {
ADD_ATTR: ['target', 'rel', 'id'],
ALLOWED_URI_REGEXP,
FORBID_TAGS: ['svg', 'math', 'template', 'form', 'input', 'button', 'select', 'textarea'],
});
}
describe('ALLOWED_URI_REGEXP 单测', () => {
it.each([
['https://e/x', true],
['http://e/x', true],
['mailto:x@y', true],
['tel:1234', true],
['callto:x', true],
['file:///C:/x.png', true],
['data:image/png;base64,AAA', true],
['#anchor', true],
['img/a.png', true],
['./other.md', true],
['/abs/x.png', true],
['x.png', true],
['../up.md', true],
// Windows 反斜杠路径(用户在 Windows 上写 `![img\foo.png](img\foo.png)`
['img\\foo.png', true],
['.\\foo.png', true],
['..\\up.md', true],
['\\abs\\x.png', true],
['javascript:alert(1)', false],
['vbscript:msgbox(1)', false],
['data:text/html,xxx', false],
['about:blank', false],
['chrome-extension://x/y', false],
// 协议相对 URL// 或 \\ 开头会被浏览器解析为「继承当前 scheme」
// 攻击面是「静默导航到外站 / 把用户隐私发给 evil.com」。
// 必须与绝对路径 /abs/x.png 区分开 —— 单 / 放行,双 / 拒绝。
['//evil.com/x.png', false],
['\\\\evil.com\\x.png', false],
])('"%s" 匹配 = %s', (input, expected) => {
expect(ALLOWED_URI_REGEXP.test(input)).toBe(expected);
});
});
describe('XSS 防护', () => {
it('<script> 被剥离', () => {
const html = render('# x\n\n<script>alert(1)</script>');
expect(html).not.toMatch(/<script/i);
});
it('onerror= 事件属性被剥离', () => {
const html = render('<img src="x" onerror="alert(1)">');
expect(html).not.toMatch(/onerror/i);
});
it('javascript: 在 <a href> 被剥离', () => {
const html = render('[c](javascript:alert(1))');
expect(html).not.toMatch(/javascript:/i);
});
it('vbscript: 被剥离', () => {
const html = render('[c](vbscript:msgbox(1))');
expect(html).not.toMatch(/vbscript:/i);
});
it('data:text/html 被剥离(仅 data:image/* 放行)', () => {
const html = render('[c](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/href="data:text\/html/i);
});
it('<iframe> 标签被剥离', () => {
const html = render('<iframe src="https://x"></iframe>');
expect(html).not.toMatch(/<iframe/i);
});
it('<form action="javascript:"> action 被剥离', () => {
const html = render('<form action="javascript:alert(1)"><input></form>');
expect(html).not.toMatch(/action="javascript:/i);
});
it('//evil.com/x.png协议相对 URL在 <a href> 被剥离', () => {
// 之前 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 在 <img src> 被剥(不依赖 DOMPurify 内置)', () => {
const html = render('![a](//evil.com/x.png)');
expect(html).not.toMatch(/src="[^"]*evil\.com/i);
});
});
describe('<img src> 保留策略', () => {
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(`![a](${src})`);
expect(html).toContain(`src="${src}"`);
});
it('javascript: 在 <img src> 被剥hook 主动剥,不依赖 DOMPurify 内置)', () => {
// 现在所有 src/href/action 都走 DANGEROUS_URI_REGEXP 主动剥 —— 不再依赖
// DOMPurify v3 内置兜底(虽然 v3 仍会兜底剥 javascript:,但 hook 是第一道闸)。
const html = render('![a](javascript:alert(1))');
expect(html).not.toMatch(/src="javascript:/);
});
it('vbscript: 在 <img src> 被剥', () => {
// 旧实现对 vbscript: 放行keepAttr=true—— DOMPurify v3 不兜底这个。
// 现在由 hook 主动剥,与 javascript: 行为一致。
const html = render('![a](vbscript:msgbox(1))');
expect(html).not.toMatch(/src="vbscript:/i);
});
it('about:blank 在 <img src> 被剥policy 统一:未在白名单的 scheme 一律不放)', () => {
// 旧实现会放行 about:blank不是危险协议但也不在白名单现在统一被剥。
const html = render('![a](about:blank)');
expect(html).not.toMatch(/src="about:/i);
});
it('data:text/html 在 <img src> 被剥(统一策略:仅 data:image/* 放行)', () => {
// 之前测试断言「保留」(理由是浏览器不把 img 渲染为 HTML—— 但跨浏览器 / 跨版本
// 一致性差,且与 ALLOWED_URI_REGEXP 单点策略不一致。现在统一被剥。
const html = render('![a](data:text/html,<script>x</script>)');
expect(html).not.toMatch(/src="data:text\/html/i);
});
// fix(audit 2026-08)SVG 数据 URL 在 <a href> 上点击会导航到 top-level SVG
// 上下文,现代 Chromium 多半拦截脚本执行,但跨浏览器一致性差。
// ALLOWED_URI_REGEXP 限定 data:image 为栅格格式png/jpeg/gif/webp/...
// 显式拒绝 svg+xml —— Markdown 几乎不会内嵌 SVGraster 已覆盖 99% 场景。
it('data:image/svg+xml 在 <img src> 被剥(栅格白名单显式排除 SVG', () => {
const html = render('![a](data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnPjxzY3JpcHQ+YWxlcnQoMSk8L3NjcmlwdD48L3N2Zz4=)');
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.comCVE-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 在 <img src> 被剥', (_label, src) => {
const html = render(`![a](${src})`);
expect(html).not.toMatch(/src="file:/i);
});
it('file:/// 三斜杠(无 host在 <img src> 仍保留(合法本地图片)', () => {
const html = render('![a](file:///C:/Users/me/Notes/x.png)');
expect(html).toContain('src="file:///C:/Users/me/Notes/x.png"');
});
// audit fix (Round 13 / Sec-M):项目里 markdown 保留合法 inline stylecolor /
// 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 全屏覆盖层', '<a href="https://phish.example" style="position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:2147483647;display:block">x</a>'],
['display:none不让用户看到真实 UI', '<div style="display:none">hidden</div>'],
['opacity:0透明覆盖层', '<div style="opacity:0;position:absolute;top:0;left:0;width:100vw;height:100vh">overlay</div>'],
['visibility:hidden', '<span style="visibility:hidden">x</span>'],
['pointer-events:none透传点击到下层', '<span style="pointer-events:none;position:absolute;inset:0">trap</span>'],
['transform绕过父级 contain', '<span style="transform:translate(-100vw,-100vh)">off-screen</span>'],
['content注入伪元素文本', '<span style="content:&apos;会话已过期&apos;">x</span>'],
['top/left/right/bottom/inset绝对定位', '<span style="position:absolute;top:0;left:0">x</span>'],
])('style 含危险属性 %s → style 属性被剥', (_label, htmlIn) => {
const html = renderHtml(htmlIn);
expect(html).not.toMatch(/style=/i);
});
it('合法 stylecolor / font-size / text-align保留', () => {
// 视觉自定义应通过合法 CSS 属性而非 inline style —— 但项目历史包袱允许
// 一些纯样式 style。回归测试确保 denylist 没误伤。
const html = renderHtml('<span style="color:red;font-size:14px;text-align:center">x</span>');
expect(html).toMatch(/color\s*:\s*red/i);
expect(html).toMatch(/font-size\s*:\s*14px/i);
});
it('<svg> 标签被剥(防 SVG-namespaced <style> 注入文档级 CSS', () => {
const html = renderHtml('<svg><style>body::after{content:"phish"}</style></svg>');
expect(html).not.toMatch(/<svg/i);
expect(html).not.toMatch(/phish/);
});
it('<form>/<input> 被剥', () => {
const html = renderHtml('<form action="https://evil.example"><input type="text" name="x"></form>');
expect(html).not.toMatch(/<form/i);
expect(html).not.toMatch(/<input/i);
});
it('data:image/png 仍保留(栅格白名单基线)', () => {
const html = render('![a](data:image/png;base64,iVBORw0KGgo=)');
expect(html).toMatch(/src="data:image\/png;base64,iVBORw0KGgo="/);
});
});
describe('非 IMG 标签的 file: 协议', () => {
it('<a href="file://..."> 被剥', () => {
const html = render('[l](file:///C:/x.txt)');
expect(html).not.toMatch(/href="file:\/\/\/C:\/x\.txt/);
});
it('<form action="file://..."> action 被剥', () => {
const html = render('<form action="file:///C:/x"><input></form>');
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('<a href="data:image/svg+xml;..."> 被剥(非 IMG 也走 ALLOWED', () => {
const html = render('[l](data:image/svg+xml;base64,PHN2Zz4=)');
expect(html).not.toMatch(/href="data:image\/svg\+xml/i);
});
it('<a href="https://..."> 仍保留(普通 https 不被误剥)', () => {
const html = render('[l](https://example.com)');
expect(html).toMatch(/href="https:\/\/example\.com"/);
});
it('target=_blank 自动注入的 rel="noopener noreferrer" 不被误剥', () => {
// 之前 bugnon-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('<img src> data:image 大小上限 (audit Sec-M3)', () => {
it('正常大小 data:image/png base64 保留', () => {
const html = render('![a](data:image/png;base64,AAAA)');
expect(html).toMatch(/src="data:image\/png;base64,AAAA"/);
});
it('巨大 data:image (>10MB) 被剥 src', () => {
// 绕过 marked 直接 sanitizemarked 的 lexer 在 11MB base64 上会先栈溢出,
// 走 marked 路径反而测不到我们要保护的那一层。这里手搓 <img> 直接喂给
// DOMPurify —— 验证 hook 在 data:image 巨长 base64 上会主动 keepAttr=false。
const huge = `data:image/png;base64,${'A'.repeat(11 * 1024 * 1024)}`;
const html = DOMPurifyInstance.sanitize(`<img src="${huge}">`, {
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(`![x](${small})`);
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(`![x](${longHttps})`);
expect(html).toMatch(/^<p><img src="https:\/\/e\.com\//);
});
});
// audit fix (Round 9)style 属性里嵌的危险 CSS 模式javascript:/expression/
// behavior/-moz-binding/@import走 DANGEROUS_STYLE_PATTERNS 主动剥。
// DOMPurify v3 自带部分保护,但跨版本一致性差;这里做防御性深度扫描。
describe('style 属性危险 CSS 模式Round 9 防御性深度扫描)', () => {
it('style 里 url(javascript:...) 被剥', () => {
const html = render('<a href="https://e.com" style="background:url(javascript:alert(1))">x</a>');
expect(html).not.toMatch(/url\s*\(\s*javascript:/i);
// 整条 style 被剥(保守策略)
expect(html).not.toMatch(/style=/i);
});
it('style 里 url("javascript:...") 引号包裹也被剥', () => {
const html = render('<span style="background:url(\'javascript:alert(1)\')">x</span>');
expect(html).not.toMatch(/javascript:/i);
});
it('style 里 url(VBScript:) 大小写不敏感也被剥', () => {
const html = render('<span style="background:url(VBScript:msgbox(1))">x</span>');
expect(html).not.toMatch(/vbscript:/i);
});
it('style 里 url(data:text/html...) 被剥(拒绝非 image data:', () => {
const html = render('<span style="background:url(data:text/html,<script>alert(1)</script>)">x</span>');
expect(html).not.toMatch(/data:text\/html/i);
});
it('style 里 expression(alert(1)) legacy IE 模式被剥', () => {
const html = render('<div style="width:expression(alert(1))">x</div>');
expect(html).not.toMatch(/expression\s*\(/i);
});
it('style 里 behavior:url(...) legacy IE HTC 模式被剥', () => {
const html = render('<div style="behavior:url(#default#anchorclick)">x</div>');
expect(html).not.toMatch(/behavior\s*:/i);
});
it('style 里 -moz-binding:url(...) legacy Mozilla XBL 模式被剥', () => {
const html = render('<div style="-moz-binding:url(\'http://evil/xbl.xml#x\')">x</div>');
expect(html).not.toMatch(/-moz-binding/i);
});
it('style 里 @import 外链资源加载被剥', () => {
const html = render('<div style="@import url(http://evil/x.css); color:red">x</div>');
expect(html).not.toMatch(/@import/i);
});
it('合法 style 保留color / font-size 等无害属性不动)', () => {
const html = render('<span style="color:red; font-size:14px">x</span>');
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('<div style="background:url(https://e.com/x.png); color:red">x</div>');
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(/<h2 id="你好世界">/);
});
it('同名标题生成 -1/-2 后缀', () => {
const html = render('## 你好世界\n\n## 你好世界');
expect(html).toMatch(/id="你好世界"/);
expect(html).toMatch(/id="你好世界-1"/);
});
});