7506fef875
审查修复四项: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>
324 lines
18 KiB
TypeScript
324 lines
18 KiB
TypeScript
// 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>'],
|
||
['实体编码协议(: 解码后判定)', '<a href="javascript: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 }))
|
||
})
|
||
})
|