Files
cms-website/packages/adapter-wordpress/test/html-sanitize.test.ts
T
浅念 7506fef875 fix(adapter-wp): srcset 审计精确化(parse-srcset);false 形态防御;复用 launder.naughtyHref;等价测试参数化
审查修复四项:srcset 逐候选审计改用 parse-srcset 同库实现(data: 逗号内嵌
URL 不再误切;全候选被拒/解析异常/零候选→整属性删除记账,含空 srcset);
deriveMirrorView 对 false/缺省形态 fail fast(allowedTags/allowedAttributes
的 false=库全放行语义与镜像空集解释相反,修正原注释);URL 判定复用
launder.naughtyHref(sanitize-html 2.18 同一实现路径,删手写镜像);
仪表化等价测试参数化为 15 个对抗样本(实体/控制字符/NULL/URL 注释走私、
未引号/重复属性、深嵌套、srcset 形态)。测试 204→228。

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

324 lines
18 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, { type IOptions } from 'sanitize-html'
import { parseWxrStream } from '../src/parse/wxr-stream.js'
import {
deriveMirrorView,
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('srcset 逐候选精确审计(parse-srcset 同库实现,非逗号粗切)', () => {
it('全候选 data:(URL 内嵌逗号不误切)→ 整属性删除 + removed-attr diff', () => {
const result = sanitizeBodyHtml(
'<img src="https://ok.example/f.png" srcset="data:image/png;base64,AAAA 1x, data:image/gif;base64,BBBB 2x" />',
)
expect(result.html).toBe('<img src="https://ok.example/f.png" />')
expect(result.diffs).toStrictEqual([
{
type: 'removed-attr',
tag: 'img',
attr: 'srcset',
excerpt: 'srcset="data:image/png;base64,AAAA 1x, data:image/gif;base64,BBBB 2x"',
},
])
})
it('混合候选(data: 恶 + https 善)→ 库仅重写为善候选,属性保留不记账', () => {
const result = sanitizeBodyHtml(
'<img srcset="data:image/png;base64,AAAA 1x, https://ok.example/a.png 2x" alt="s" />',
)
expect(result.html).toBe('<img srcset="https://ok.example/a.png 2x" alt="s" />')
expect(result.diffs).toStrictEqual([]) // 部分被拒 = 重写而非移除
})
it('不可解析 srcset(,,,,,,,零候选)→ 整属性删除 + removed-attr diff', () => {
const result = sanitizeBodyHtml(
'<img src="https://ok.example/a.png" srcset=",,,,,," alt="u" />',
)
expect(result.html).toBe('<img src="https://ok.example/a.png" alt="u" />')
expect(result.diffs).toStrictEqual([
{ type: 'removed-attr', tag: 'img', attr: 'srcset', excerpt: 'srcset=",,,,,,"' },
])
})
})
describe('deriveMirrorView:配置形态防御(fail fast,程序员错误)', () => {
it('SANITIZE_CONFIG 现形(显式表)通过断言并产出镜像视图', () => {
const view = deriveMirrorView(SANITIZE_CONFIG)
expect(view.tags.has('p')).toBe(true)
expect([...(view.attrsByTag.get('a') ?? [])]).toContain('href')
expect(view.schemes).toStrictEqual(['http', 'https', 'mailto', 'tel'])
expect(view.schemeAttrs.has('src')).toBe(true)
})
// 显式注解防字面量拓宽(false → boolean 会脱离 IOptions 联合)
const badForms: ReadonlyArray<readonly [string, Partial<IOptions>]> = [
['allowedTags: false(库语义=全放行,镜像按空集解释即反转)', { allowedTags: false }],
['allowedAttributes: false(库语义=全放行)', { allowedAttributes: false }],
['allowedAttributes 缺省(库语义=库默认白名单表,非空集)', { allowedAttributes: undefined }],
['allowedSchemes: false(库语义=全拒绝,实现走 || [])', { allowedSchemes: false }],
['allowedSchemesAppliedToAttributes 缺省(库默认长清单,镜像窄于默认会漏报)', { allowedSchemesAppliedToAttributes: undefined }],
]
it.each(badForms)('%s → TypeError', (_label, override) => {
expect(() => deriveMirrorView({ ...SANITIZE_CONFIG, ...override })).toThrow(TypeError)
})
it('allowedSchemesByTag:缺省合法(≡ {},库逐属性回退全局表);boolean 形态拒绝', () => {
const { allowedSchemesByTag: _absent, ...withoutByTag } = SANITIZE_CONFIG
expect(deriveMirrorView(withoutByTag).schemes).toStrictEqual(['http', 'https', 'mailto', 'tel'])
expect(() => deriveMirrorView({ ...SANITIZE_CONFIG, allowedSchemesByTag: false })).toThrow(
TypeError,
)
})
})
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)
})
})
describe('仪表化等价(对抗样本参数化)', () => {
// 覆盖走私变形(实体/控制字符/NULL/URL 注释)、解析边界(未引号/重复属性/
// 混合大小写/深嵌套)与 srcset 形态(data: 逗号内嵌/全恶/不可解析)——这些正是
// before/after 对比会误报、粗切镜像会漏报的形态
const samples: ReadonlyArray<readonly [string, string]> = [
['script 内容移除', '<p>a</p><script>alert(1)</script>'],
['实体编码协议(&colon; 解码后判定)', '<a href="javascript&colon;alert(1)">x</a>'],
['控制字符走私(java\\u0001script:)', '<a href="java\x01script:alert(1)">x</a>'],
['NULL 字节走私(java\\u0000script:)', '<a href="java\x00script:alert(1)">x</a>'],
['URL 注释走私(java<!-- -->script:)', '<a href="java<!-- -->script:alert(1)">x</a>'],
['未加引号属性', '<a href=javascript:alert(1)>x</a>'],
['重复属性(解析器首值胜出)', '<a href="/ok/" href="javascript:x">d</a>'],
['混合大小写标签与属性', '<ScRiPt>x</ScRiPt><A HREF="JaVaScRiPt:x">y</A>'],
['iframe + 事件属性混合', '<iframe src="https://e.example/f" onload="x()">fb</iframe>'],
['多重违例混合', '<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">'],
['data: 逗号内嵌 srcset(部分重写)', '<img srcset="data:image/png;base64,AAAA 1x, https://ok.example/a.png 2x" alt="s" />'],
['全 data: srcset(整属性删除)', '<img src="https://ok.example/f.png" srcset="data:image/png;base64,AAAA 1x, data:image/gif;base64,BBBB 2x" />'],
['不可解析 srcset(,,,,,,)', '<img src="https://ok.example/a.png" srcset=",,,,,," alt="u" />'],
['深嵌套 30 层 + 内层违例', `<div>${'<div>'.repeat(29)}<p onclick="x()">deep</p>${'</div>'.repeat(29)}</div>`],
['mini.xml 101 正文', MINI_101_BODY],
]
it.each(samples)('sanitizeHtml(x, SANITIZE_CONFIG) ≡ sanitizeBodyHtml(x).html:%s', (_label, sample) => {
expect(sanitizeBodyHtml(sample).html).toBe(sanitizeHtml(sample, { ...SANITIZE_CONFIG }))
})
})