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>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user