Files
cms-website/packages/adapter-wordpress/test/html-sanitize.test.ts
T
浅念 8dd6eaf1b6 feat(adapter-wp): HTML 白名单清洗与差异收集
sanitize-html@^2(MIT)白名单清洗 + onOpenTag 观察钩子按文档序收集 SanitizeDiff[]
(镜像判定从 SANITIZE_CONFIG 单源推导;仪表化与清洗输出字节等价,测试钉死)。
白名单决策冻结:h1/布局标签/内联样式/iframe 拒绝(diff 留痕),外链 img 含
1x1 追踪像素本切片保留(远程依赖报告归 Task 6,路径重写归阶段 3)。
测试 184→204。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-21 10:39:37 +08:00

233 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Task 5 失败测试(先于实现):WordPress 正文 HTML 白名单清洗 + SanitizeDiff[] 差异收集。
// 方案取舍(实现侧 JSDoc 为准):清洗本体交 sanitize-html;差异经 onOpenTag 观察钩子
// 按文档序收集(每次调用独立数组,纯函数)。
import { createReadStream } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import sanitizeHtml from 'sanitize-html'
import { parseWxrStream } from '../src/parse/wxr-stream.js'
import { SANITIZE_CONFIG, sanitizeBodyHtml, type SanitizeDiff } from '../src/sanitize/html.js'
const FIXTURE = fileURLToPath(new URL('../../../tests/fixtures/wxr/mini.xml', import.meta.url))
/** mini.xml post 101 的 content:encoded 原文(item-to-row 测试同款锚点) */
const MINI_101_BODY =
'<p>Body with <a href="/products/fixture-product/">link</a> and <img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"/></p>'
describe('sanitizeBodyHtml:危险内容移除(差异留痕)', () => {
it('<script> 标签与内容整体移除 + removed-tag diff(excerpt 含开标签)', () => {
const result = sanitizeBodyHtml('<p>ok</p><script>alert(1)</script>')
expect(result.html).toBe('<p>ok</p>') // script 内文随 nonTextTags 语义一并丢弃
expect(result.diffs).toStrictEqual([{ type: 'removed-tag', tag: 'script', excerpt: '<script>' }])
})
it('<style> 内联样式标签与内容移除 + removed-tag diff(ADR-009:内联样式不入产出)', () => {
const result = sanitizeBodyHtml('<style>p { color: red; }</style><p>s</p>')
expect(result.html).toBe('<p>s</p>')
expect(result.diffs).toStrictEqual([{ type: 'removed-tag', tag: 'style', excerpt: '<style>' }])
})
it('事件属性 onclick 移除 + removed-attr diff(excerpt 为属性序列化)', () => {
const result = sanitizeBodyHtml('<p onclick="steal()">text</p>')
expect(result.html).toBe('<p>text</p>')
expect(result.diffs).toStrictEqual([
{ type: 'removed-attr', tag: 'p', attr: 'onclick', excerpt: 'onclick="steal()"' },
])
})
it('危险协议:javascript: href 与 data: img src 均移除属性 + removed-attr diff', () => {
const js = sanitizeBodyHtml('<a href="javascript:alert(1)">x</a>')
expect(js.html).toBe('<a>x</a>') // 属性被整体删除,标签保留
expect(js.diffs).toStrictEqual([
{ type: 'removed-attr', tag: 'a', attr: 'href', excerpt: 'href="javascript:alert(1)"' },
])
const data = sanitizeBodyHtml('<img src="data:image/png;base64,AAAA" alt="d" />')
expect(data.html).toBe('<img alt="d" />')
expect(data.diffs).toStrictEqual([
{
type: 'removed-attr',
tag: 'img',
attr: 'src',
excerpt: 'src="data:image/png;base64,AAAA"',
},
])
})
it('未批准 iframe:标签移除 + removed-tag diff(allowIframe:false 等价物 = 白名单缺席)', () => {
const result = sanitizeBodyHtml('<iframe src="https://evil.example/embed" width="600">fb</iframe>tail')
// discard 语义:剥壳保留内文(iframe 后备文本)
expect(result.html).toBe('fbtail')
expect(result.diffs).toStrictEqual([
{
type: 'removed-tag',
tag: 'iframe',
excerpt: '<iframe src="https://evil.example/embed" width="600">',
},
])
})
it('多重违例全部记录且顺序稳定(文档序:标签按出现、属性按声明)', () => {
const dirty =
'<p onclick="a()">t1</p><script>evil()</script><a href="javascript:x">t2</a><iframe src="https://e.example/f">fb</iframe><img src="https://ok.example/p.png" onerror="y()" alt="ok">'
const result = sanitizeBodyHtml(dirty)
expect(result.html).toBe('<p>t1</p><a>t2</a>fb<img src="https://ok.example/p.png" alt="ok" />')
expect(result.diffs).toStrictEqual([
{ type: 'removed-attr', tag: 'p', attr: 'onclick', excerpt: 'onclick="a()"' },
{ type: 'removed-tag', tag: 'script', excerpt: '<script>' },
{ type: 'removed-attr', tag: 'a', attr: 'href', excerpt: 'href="javascript:x"' },
{ type: 'removed-tag', tag: 'iframe', excerpt: '<iframe src="https://e.example/f">' },
{ type: 'removed-attr', tag: 'img', attr: 'onerror', excerpt: 'onerror="y()"' },
] satisfies SanitizeDiff[])
})
})
describe('sanitizeBodyHtml:白名单原样保留', () => {
it('任务清单全标签结构字节级保留(含 srcset img / 表格 / figure / pre:code),零 diff', () => {
const doc =
'<h2 id="intro">H2</h2><h3>H3</h3><h4>H4</h4><h5>H5</h5><h6>H6</h6>' +
'<p class="lead">Para with <strong>bold</strong>, <em>em</em>, ' +
'<a href="/products/fixture-product/" target="_blank" rel="noopener">link</a> and ' +
'<a href="mailto:sales@example.com">mail</a>.</p>' +
'<ul><li>one</li><li>two</li></ul><ol start="3"><li>three</li></ol>' +
'<figure class="wp-block-image"><img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png" srcset="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png 1x, https://fixture.example/wp-content/uploads/2026/10/fixture-a@2x.png 2x" sizes="(max-width: 600px) 100vw, 600px" alt="Fixture A" width="600" height="400" loading="lazy" decoding="async" /><figcaption>Caption</figcaption></figure>' +
'<blockquote cite="https://example.com/source"><p>Quote</p></blockquote><pre><code>x = 1</code></pre>' +
'<table><colgroup><col /></colgroup><thead><tr><th scope="col">A</th></tr></thead>' +
'<tbody><tr><td colspan="2">1</td></tr></tbody><tfoot><tr><td>f</td></tr></tfoot></table>' +
'<div class="wp-group"><span class="note">span</span><br /><hr /><b>b</b><i>i</i><u>u</u><s>s</s><del>d</del><ins>n</ins><sub>1</sub><sup>2</sup><small>sm</small><mark>m</mark><abbr title="X">ab</abbr><cite>c</cite><q>q</q><time datetime="2026-10-05">t</time><kbd>k</kbd><samp>s</samp><var>v</var><dl><dt>t</dt><dd>d</dd></dl><details open><summary>s</summary>body</details></div>'
const result = sanitizeBodyHtml(doc)
expect(result.diffs).toStrictEqual([])
expect(result.html).toBe(doc)
})
it('URL 形态:相对路径 / 协议相对 / tel 全部原样保留,零 diff', () => {
const doc =
'<p><a href="/about/">rel</a> <a href="//cdn.example.com/x">proto-rel</a> <a href="tel:+861200000000">tel</a></p>'
const result = sanitizeBodyHtml(doc)
expect(result.html).toBe(doc)
expect(result.diffs).toStrictEqual([])
})
})
describe('sanitizeBodyHtml:mini.xml 端到端(parseWxrStream → sanitizeBodyHtml)', () => {
it('post(101) contentEncoded:内链 href 与外链图 URL 原样、零 diff(img 规范形补空格自闭合)', async () => {
const items = []
for await (const ev of parseWxrStream(createReadStream(FIXTURE))) {
if (ev.kind === 'item' && ev.item.postId === 101) items.push(ev.item)
}
expect(items).toHaveLength(1)
expect(items[0]?.contentEncoded).toBe(MINI_101_BODY) // 输入锚点:原文透传
const result = sanitizeBodyHtml(items[0]?.contentEncoded ?? '')
expect(result.diffs).toStrictEqual([])
// URL 原样保留(内链路径 + 外链图片完整 URL),仅 img 自闭合规范化为 " />"
expect(result.html).toBe(
'<p>Body with <a href="/products/fixture-product/">link</a> and <img src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png" /></p>',
)
expect(result.html).toContain('href="/products/fixture-product/"')
expect(result.html).toContain('src="https://fixture.example/wp-content/uploads/2026/10/fixture-a.png"')
})
})
describe('sanitizeBodyHtml:差异收集机制', () => {
it('纯函数:同输入两次清洗,html 与 diffs 全等(无共享状态)', () => {
const dirty =
'<div onclick="go()"><a href="javascript:x">in</a></div><script>s()</script><iframe src="https://e.example/i"></iframe>'
const first = sanitizeBodyHtml(dirty)
const second = sanitizeBodyHtml(dirty)
expect(second).toStrictEqual(first)
expect(first.diffs).toHaveLength(4) // div.onclick / a.href / script / iframe
})
it('大小写归一:<SCRIPT>/<A HREF> 以小写 tag/attr 记 diff', () => {
const result = sanitizeBodyHtml('<SCRIPT>x</SCRIPT><A HREF="javascript:y">z</A>')
expect(result.html).toBe('<a>z</a>')
expect(result.diffs).toStrictEqual([
{ type: 'removed-tag', tag: 'script', excerpt: '<script>' },
{ type: 'removed-attr', tag: 'a', attr: 'href', excerpt: 'href="javascript:y"' },
])
})
it('excerpt 上限 80 字符;控制字符转义为 \\uXXXX 形式', () => {
const long = sanitizeBodyHtml(`<p onclick="${'A'.repeat(200)}">x</p>`)
const diff = long.diffs[0]
expect(diff?.excerpt).toHaveLength(80)
expect(diff?.excerpt.startsWith('onclick="')).toBe(true)
// 真实控制字符(U+0001)进属性值 → excerpt 以 \u0001 转义文本呈现
const ctrl = sanitizeBodyHtml('<p onclick="\u0001abc">t</p>')
expect(ctrl.diffs[0]?.excerpt).toBe('onclick="\\u0001abc"')
})
it('__proto__ / constructor 属性名安全:不抛错、不污染 Object.prototype', () => {
const result = sanitizeBodyHtml('<p __proto__="v" constructor="c">x</p>')
expect(result.html).toBe('<p>x</p>')
expect(({} as Record<string, unknown>).v).toBeUndefined() // 原型未被污染
// constructor 为普通未批准属性 → removed-attr;__proto__ 被解析器吞掉(不可见即不可报)
for (const diff of result.diffs) {
expect(diff.type).toBe('removed-attr')
expect(diff.tag).toBe('p')
}
expect(result.diffs.some((d) => d.attr === 'constructor')).toBe(true)
})
})
describe('sanitizeBodyHtml:边界输入', () => {
it('空串 → 空串、零 diff', () => {
expect(sanitizeBodyHtml('')).toStrictEqual({ html: '', diffs: [] })
})
it('纯文本 → 原样、零 diff', () => {
expect(sanitizeBodyHtml('Hello World')).toStrictEqual({ html: 'Hello World', diffs: [] })
})
})
describe('追踪像素策略(本切片钉死:外链 img 一律保留,含 1x1)', () => {
it('1x1 外链 img 原样保留(宽高属性一并保留),零 diff', () => {
const doc = '<p><img src="https://tracker.example/px.gif" width="1" height="1" alt="" /></p>'
const result = sanitizeBodyHtml(doc)
expect(result.html).toBe(doc)
expect(result.diffs).toStrictEqual([])
})
})
describe('SANITIZE_CONFIG:白名单冻结与钉死', () => {
it('配置对象冻结(浅层),allowedTags 精确清单(无 script/style/iframe/form/svg/h1)', () => {
expect(Object.isFrozen(SANITIZE_CONFIG)).toBe(true)
expect(SANITIZE_CONFIG.allowedTags).toStrictEqual([
'a', 'abbr', 'b', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup',
'dd', 'del', 'details', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h2',
'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li',
'mark', 'ol', 'p', 'pre', 'q', 's', 'samp', 'small', 'span', 'strong',
'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
'tr', 'u', 'ul', 'var',
])
// allowIframe:false 等价物 = iframe 不在白名单(无 allowedIframeHosts 通道)
expect(SANITIZE_CONFIG.allowedTags).not.toContain('iframe')
expect(SANITIZE_CONFIG.allowedTags).not.toContain('script')
expect(SANITIZE_CONFIG.allowedTags).not.toContain('style')
expect(SANITIZE_CONFIG.allowedTags).not.toContain('h1')
})
it('allowedAttributes 关键表钉死:a 链接属性 / img 图像属性 / 表格单元格 / 通用 class|id', () => {
// 类型联合含 false 形态(运行时不可能,配置钉死为表)——测试断言侧收窄
const attrs = SANITIZE_CONFIG.allowedAttributes as Record<string, string[]>
expect(attrs['*']).toStrictEqual(['class', 'dir', 'id', 'lang', 'title'])
expect(attrs.a).toStrictEqual(['href', 'name', 'rel', 'target'])
expect(attrs.img).toStrictEqual([
'alt', 'decoding', 'height', 'loading', 'sizes', 'src', 'srcset', 'width',
])
expect(attrs.td).toStrictEqual(['colspan', 'headers', 'rowspan', 'scope'])
expect(attrs.th).toStrictEqual(['abbr', 'colspan', 'headers', 'rowspan', 'scope'])
})
it('协议白名单钉死:http/https/mailto/tel;协议相对 URL 放行', () => {
expect(SANITIZE_CONFIG.allowedSchemes).toStrictEqual(['http', 'https', 'mailto', 'tel'])
expect(SANITIZE_CONFIG.allowProtocolRelative).toBe(true)
})
it('仪表化不影响输出:sanitizeHtml(x, SANITIZE_CONFIG) 与 sanitizeBodyHtml(x).html 逐字节一致', () => {
const dirty =
'<p onclick="a()">t1</p><script>evil()</script><a href="javascript:x">t2</a><iframe src="https://e.example/f">fb</iframe><img src="https://ok.example/p.png" onerror="y()" alt="ok">'
expect(sanitizeHtml(dirty, { ...SANITIZE_CONFIG })).toBe(sanitizeBodyHtml(dirty).html)
expect(sanitizeHtml(MINI_101_BODY, { ...SANITIZE_CONFIG })).toBe(sanitizeBodyHtml(MINI_101_BODY).html)
})
})