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>
This commit is contained in:
@@ -4,9 +4,14 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import sanitizeHtml, { type IOptions } from 'sanitize-html'
|
||||
import { parseWxrStream } from '../src/parse/wxr-stream.js'
|
||||
import { SANITIZE_CONFIG, sanitizeBodyHtml, type SanitizeDiff } from '../src/sanitize/html.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))
|
||||
|
||||
@@ -169,6 +174,72 @@ describe('sanitizeBodyHtml:差异收集机制', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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: [] })
|
||||
@@ -222,11 +293,31 @@ describe('SANITIZE_CONFIG:白名单冻结与钉死', () => {
|
||||
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)
|
||||
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 }))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user