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:
浅念
2026-10-21 11:18:39 +08:00
parent 8dd6eaf1b6
commit 7506fef875
5 changed files with 232 additions and 93 deletions
+3
View File
@@ -18,10 +18,13 @@
}, },
"dependencies": { "dependencies": {
"@sf/contracts": "workspace:*", "@sf/contracts": "workspace:*",
"launder": "^1.7.2",
"parse-srcset": "^1.0.2",
"sanitize-html": "^2", "sanitize-html": "^2",
"saxes": "^6.0.0" "saxes": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/parse-srcset": "^1.0.0",
"@types/sanitize-html": "^2.16.2" "@types/sanitize-html": "^2.16.2"
} }
} }
+105 -86
View File
@@ -16,18 +16,22 @@
// e) 正则预扫描——非 tokenizer 精度(注释/引号内 '>'/大小式变形),仅可作为 // e) 正则预扫描——非 tokenizer 精度(注释/引号内 '>'/大小式变形),仅可作为
// 下策。onOpenTag 复用 sanitize-html 自身解析器(htmlparser2)的事件流, // 下策。onOpenTag 复用 sanitize-html 自身解析器(htmlparser2)的事件流,
// 精度与清洗本体同源,故选 a。 // 精度与清洗本体同源,故选 a。
// - 镜像逻辑(判断"什么会被拒")从 SANITIZE_CONFIG 单源推导,不另立策略表: // - "什么会被拒"的判定与清洗本体**同库同源**(零手写镜像):
// 标签 ∈ allowedTags?属性 ∈ allowedAttributes[tag] ∪ allowedAttributes['*']? // tag/attr 白名单从 SANITIZE_CONFIG 单源派生({@link deriveMirrorView});
// URL 属性(allowedSchemesAppliedToAttributes)协议 ∈ allowedSchemes // URL 判定复用 launder.naughtyHref(sanitize-html 2.18 内部即解构调用同一函数,
// (含 allowedSchemesByTag 覆盖与协议相对 URL 规则,语义镜像 launder.naughtyHref)。 // 控制字符/HTML 注释走私、scheme 前缀、协议相对语义由其保证);srcset 解析复用
// - 不镜像的清洗路径( exotic,报告中不体现;清洗本体不受影响): // parse-srcset(同库同版本),逐候选判定,"全候选被拒/解析异常/零候选"→库整属性
// 属性名合法性校验、空值非布尔属性删除、meta refresh content、SVG animate 名单、 // 删除 → 记 removed-attr(含空 srcset;部分候选被拒仅重写值,属性仍在,不记账)。
// allowedClasses 过滤、srcset 部分候选重写(仅"全部候选被拒→整属性删除"记账, // - 不镜像的清洗路径(exotic,报告中不体现;清洗本体不受影响):
// 候选切分为近似逗号分割)、空 srcset 删除。 // 属性名合法性校验(VALID_HTML_ATTRIBUTE_NAME)、空值非布尔属性删除、
// meta refresh content、SVG animate 名单、allowedClasses 过滤。
// - 记账粒度:按**白名单拒绝的开标签事件**逐次记录(文档序;属性按声明序)—— // - 记账粒度:按**白名单拒绝的开标签事件**逐次记录(文档序;属性按声明序)——
// 不按"最终 DOM 差异"去重:discard 剥壳语义下未批准容器的子内容会被解包保留, // 不按"最终 DOM 差异"去重:discard 剥壳语义下未批准容器的子内容会被解包保留,
// 其属性过滤真实发生,同样值得留痕;宁多报不漏报(审计定位用途)。 // 其属性过滤真实发生,同样值得留痕;宁多报不漏报(审计定位用途)。
// 顺序确定 → 同输入必得同 diffs(纯函数,无共享状态,测试钉死)。 // 顺序确定 → 同输入必得同 diffs(纯函数,无共享状态,测试钉死)。
// - 配置形态防御:镜像只支持显式表形态,其他形态(false/缺省)的库语义与镜像
// 解释相反(如 allowedTags:false = 全放行),模块加载即 fail fast——见
// {@link deriveMirrorView}。
// //
// 白名单范围决策(冻结;改动须评审,测试钉死值域): // 白名单范围决策(冻结;改动须评审,测试钉死值域):
// - h1 不入白名单:标题层级归主题层(文章标题由主题渲染 h1),正文从 h2 起; // - h1 不入白名单:标题层级归主题层(文章标题由主题渲染 h1),正文从 h2 起;
@@ -46,6 +50,8 @@
// 阶段 3;本模块不越权。 // 阶段 3;本模块不越权。
import sanitizeHtml, { type Attributes, type IOptions } from 'sanitize-html' import sanitizeHtml, { type Attributes, type IOptions } from 'sanitize-html'
import { naughtyHref } from 'launder'
import parseSrcset from 'parse-srcset'
/** 单条清洗差异:一次被白名单拒绝的标签/属性出现(审计与报告的最小记账单元) */ /** 单条清洗差异:一次被白名单拒绝的标签/属性出现(审计与报告的最小记账单元) */
export interface SanitizeDiff { export interface SanitizeDiff {
@@ -111,88 +117,96 @@ export const SANITIZE_CONFIG: Readonly<IOptions> = Object.freeze({
/** excerpt 上限(字符):审计摘要够定位即可,控制报告体积 */ /** excerpt 上限(字符):审计摘要够定位即可,控制报告体积 */
const EXCERPT_MAX = 80 const EXCERPT_MAX = 80
// ---- 镜像判定的派生索引(SANITIZE_CONFIG 冻结 → 模块级一次派生,纯常量视图)---- /** 镜像判定所需的配置视图(形态断言 + 派生索引一次完成的产物) */
interface MirrorView {
/** 白名单标签集(小写) */
tags: ReadonlySet<string>
/** 白名单属性表(含 '*' 通配;AllowedAttribute 受限值形态未启用,镜像只认字符串) */
attrsByTag: ReadonlyMap<string, ReadonlySet<string>>
/** 全局协议白名单(小写) */
schemes: readonly string[]
/** 按标签覆盖的协议表(本配置未用,机制保留) */
schemesByTag: ReadonlyMap<string, readonly string[]>
/** 参与协议判定的属性名集 */
schemeAttrs: ReadonlySet<string>
allowProtocolRelative: boolean
}
// allowedTags 的 false 形态(全拒绝)语义上等于空集——镜像统一为空 Set /**
const TAGS_CFG = SANITIZE_CONFIG.allowedTags * 配置形态防御 + 镜像派生索引(模块初始化即对 SANITIZE_CONFIG 调用;导出仅为
const ALLOWED_TAGS: ReadonlySet<string> = new Set( * 测试与未来动态配置消费方复检)。
TAGS_CFG === false || TAGS_CFG === undefined ? [] : TAGS_CFG, *
) * 镜像判定只支持**显式表形态**,其余形态的库语义(实测 sanitize-html 2.18.1):
* - allowedTags / allowedAttributes 传 `false` = **全放行**(allow-all——标签/属性
* 过滤整体旁路);缺省 = 库自带默认白名单表。
* - allowedSchemes 传 `false` = **全拒绝**(实现走 `options.allowedSchemes || []`,
* 一切协议被判 naughty)。
* 这些形态若被镜像按"空集"解释,diff 报告会与清洗本体**静默反转**(全放行被报告
* 成全移除、全拒绝报告成无协议约束)。故本函数对不支持形态直接抛 TypeError:
* 配置漂移在模块加载即炸,不产出南辕北辙的报告。allowedSchemesByTag 例外:
* 缺省与 `{}` 等价(库逐属性回退全局表),允许缺省;boolean 形态仍拒绝。
*/
export function deriveMirrorView(cfg: Readonly<IOptions>): MirrorView {
const fail = (field: string): TypeError =>
new TypeError(
`SANITIZE_CONFIG.${field} 形态不受镜像判定支持(须为显式表:allowedTags/allowedSchemes/` +
`allowedSchemesAppliedToAttributes 为 string[],allowedAttributes 为表对象,allowedSchemesByTag 为表对象或缺省):` +
`false/缺省的库语义(全放行/库默认/全拒绝)与镜像解释相反,拒绝静默漂移`,
)
if (!Array.isArray(cfg.allowedTags)) throw fail('allowedTags')
if (typeof cfg.allowedAttributes !== 'object') throw fail('allowedAttributes')
if (!Array.isArray(cfg.allowedSchemes)) throw fail('allowedSchemes')
if (!Array.isArray(cfg.allowedSchemesAppliedToAttributes)) {
throw fail('allowedSchemesAppliedToAttributes')
}
if (cfg.allowedSchemesByTag !== undefined && typeof cfg.allowedSchemesByTag !== 'object') {
throw fail('allowedSchemesByTag')
}
const ALLOWED_ATTRS = new Map<string, ReadonlySet<string>>() const attrsByTag = new Map<string, ReadonlySet<string>>()
{ for (const [tag, attrs] of Object.entries(cfg.allowedAttributes)) {
const configured = SANITIZE_CONFIG.allowedAttributes // AllowedAttribute 允许 {name, values} 受限值形态;本配置只用字符串形态,
if (configured !== false && configured !== undefined) { // 镜像只认字符串(受限值形态若未来启用,须同步扩展此处)
for (const [tag, attrs] of Object.entries(configured)) { attrsByTag.set(tag, new Set(attrs.filter((a): a is string => typeof a === 'string')))
// AllowedAttribute 允许 {name, values} 受限值形态;本配置只用字符串形态, }
// 镜像只认字符串(受限值形态若未来启用,须同步扩展此处) const schemesByTag = new Map<string, readonly string[]>()
ALLOWED_ATTRS.set( if (cfg.allowedSchemesByTag !== undefined) {
tag, for (const [tag, schemes] of Object.entries(cfg.allowedSchemesByTag)) {
new Set(attrs.filter((a): a is string => typeof a === 'string')), schemesByTag.set(tag, schemes.map((s) => s.toLowerCase()))
)
} }
} }
return {
tags: new Set(cfg.allowedTags),
attrsByTag,
schemes: cfg.allowedSchemes.map((s) => s.toLowerCase()),
schemesByTag,
schemeAttrs: new Set(cfg.allowedSchemesAppliedToAttributes),
allowProtocolRelative: cfg.allowProtocolRelative ?? true,
}
} }
// 模块初始化:配置形态 fail fast(见 deriveMirrorView JSDoc)+ 派生索引一次构建
// (SANITIZE_CONFIG 冻结 → 常量视图)
const MIRROR: MirrorView = deriveMirrorView(SANITIZE_CONFIG)
const EMPTY_ATTRS: ReadonlySet<string> = new Set() const EMPTY_ATTRS: ReadonlySet<string> = new Set()
const SCHEME_ATTRS: ReadonlySet<string> = new Set(
SANITIZE_CONFIG.allowedSchemesAppliedToAttributes ?? ['href', 'src', 'cite'],
)
// 协议表归一小写(launder 以小写比较 scheme;配置值大小写不敏感)。
// boolean 形态(false=不过滤)不参与镜像——本配置为 string[],测试钉死
const SCHEMES_CFG = SANITIZE_CONFIG.allowedSchemes
const ALLOWED_SCHEMES: readonly string[] | undefined = Array.isArray(SCHEMES_CFG)
? SCHEMES_CFG.map((s: string) => s.toLowerCase())
: undefined
const SCHEMES_BY_TAG = new Map<string, readonly string[]>()
{
const byTag = SANITIZE_CONFIG.allowedSchemesByTag
if (byTag !== false && byTag !== undefined) {
for (const [tag, schemes] of Object.entries(byTag)) {
SCHEMES_BY_TAG.set(tag, schemes.map((s: string) => s.toLowerCase()))
}
}
}
const ALLOW_PROTOCOL_RELATIVE = SANITIZE_CONFIG.allowProtocolRelative ?? true
/** /**
* launder.naughtyHref 的语义镜像(sanitize-html 2.18 经 launder 判定 URL 属性): * URL 判定复用 launder.naughtyHref——sanitize-html 2.18 内部即解构调用同一函数
* 清理(剥 \x00-\x20 与 HTML 注释——浏览器忽略它们,常用于走私 scheme)后取 * (`const { naughtyHref: launderNaughtyHref } = require('launder')`),与清洗本体
* scheme 前缀;无 scheme 时仅协议相对 URL 受 allowProtocolRelative 约束。 * 零镜像漂移;控制字符/HTML 注释走私(cleanHref)、scheme 前缀、协议相对语义随
* 镜像而非复用(launder 未导出公开契约):若库升级判定变化,测试对照 * launder 保证。协议表按标签覆盖解析(库语义:per-tag 表命中则覆盖,否则全局表)。
* (仪表化等价测试 + 协议用例)会暴露漂移。
*/ */
function isNaughtyUrl(rawUrl: string, allowedSchemes: readonly string[]): boolean { function isNaughtyUrl(url: string, allowedSchemes: readonly string[]): boolean {
// 浏览器会忽略 URL 中的控制字符(\x00-\x20,常见 scheme 走私手法,见 OWASP return naughtyHref(url, {
// XSS Filter Evasion Cheat Sheet#Embedded tab)——控制字符正是本清洗的目标,豁免 allowedSchemes,
// eslint-disable-next-line no-control-regex allowProtocolRelative: MIRROR.allowProtocolRelative,
let href = rawUrl.replace(/[\x00-\x20]+/g, '') })
for (;;) {
const start = href.indexOf('<!--')
if (start === -1) break
const end = href.indexOf('-->', start + 4)
if (end === -1) break
href = href.slice(0, start) + href.slice(end + 3)
}
const scheme = href.match(/^([a-zA-Z][a-zA-Z0-9.\-+]*):/)?.[1]
if (scheme === undefined) {
if (/^[/\\]{2}/.test(href)) return !ALLOW_PROTOCOL_RELATIVE
return false
}
return !allowedSchemes.includes(scheme.toLowerCase())
}
function schemesFor(tag: string): readonly string[] {
return SCHEMES_BY_TAG.get(tag) ?? ALLOWED_SCHEMES ?? []
} }
/** 控制字符(C0 + DEL)→ \uXXXX 转义文本(摘要在 JSON/日志里可见可 grep) */ /** 控制字符(C0 + DEL)→ \uXXXX 转义文本(摘要在 JSON/日志里可见可 grep) */
function escapeControlChars(text: string): string { function escapeControlChars(text: string): string {
// 转义控制字符是本函数职责本身,豁免(同上:字符类里写控制字符是有意为之) // 转义控制字符是本函数职责本身,豁免(字符类里写控制字符是有意为之)
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
return text.replace(/[\x00-\x1f\x7f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`) return text.replace(/[\x00-\x1f\x7f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)
} }
@@ -213,31 +227,36 @@ function openingTagMarkup(tag: string, attribs: Attributes): string {
/** /**
* 单个开标签事件的拒绝记账(onOpenTag 回调体): * 单个开标签事件的拒绝记账(onOpenTag 回调体):
* 标签被拒 → 只记 removed-tag(其属性随标签消失,不重复记账); * 标签被拒 → 只记 removed-tag(其属性随标签消失,不重复记账);
* 标签放行 → 逐属性记(未批准 / URL 协议被拒 / srcset 全候选被拒)。 * 标签放行 → 逐属性记(未批准 / URL 协议被拒 / srcset 全候选被拒或不可解析)。
*/ */
function recordRejections(diffs: SanitizeDiff[], tag: string, attribs: Attributes): void { function recordRejections(diffs: SanitizeDiff[], tag: string, attribs: Attributes): void {
if (!ALLOWED_TAGS.has(tag)) { if (!MIRROR.tags.has(tag)) {
diffs.push({ type: 'removed-tag', tag, excerpt: makeExcerpt(openingTagMarkup(tag, attribs)) }) diffs.push({ type: 'removed-tag', tag, excerpt: makeExcerpt(openingTagMarkup(tag, attribs)) })
return return
} }
const tagAttrs = ALLOWED_ATTRS.get(tag) ?? EMPTY_ATTRS const tagAttrs = MIRROR.attrsByTag.get(tag) ?? EMPTY_ATTRS
const starAttrs = ALLOWED_ATTRS.get('*') ?? EMPTY_ATTRS const starAttrs = MIRROR.attrsByTag.get('*') ?? EMPTY_ATTRS
const schemes = schemesFor(tag) const schemes = MIRROR.schemesByTag.get(tag) ?? MIRROR.schemes
for (const [attr, value] of Object.entries(attribs)) { for (const [attr, value] of Object.entries(attribs)) {
const excerpt = makeExcerpt(`${attr}=${JSON.stringify(value)}`) const excerpt = makeExcerpt(`${attr}=${JSON.stringify(value)}`)
if (!tagAttrs.has(attr) && !starAttrs.has(attr)) { if (!tagAttrs.has(attr) && !starAttrs.has(attr)) {
diffs.push({ type: 'removed-attr', tag, attr, excerpt }) diffs.push({ type: 'removed-attr', tag, attr, excerpt })
continue continue
} }
if (SCHEME_ATTRS.has(attr) && isNaughtyUrl(value, schemes)) { if (MIRROR.schemeAttrs.has(attr) && isNaughtyUrl(value, schemes)) {
diffs.push({ type: 'removed-attr', tag, attr, excerpt }) diffs.push({ type: 'removed-attr', tag, attr, excerpt })
continue continue
} }
// srcset/imagesrcset 逐候选协议过滤的镜像:全部候选被拒时库会整属性删除(近似 // srcset/imagesrcset 与库同路径:parse-srcset 解析 + launder.naughtyHref 逐候选
// 逗号切分,仅 diff 判定用);部分被拒仅重写不删除,不记账 // 判定。存活候选为零(全被拒/解析异常/零候选——含空串,库一律整属性删除)→
// 记 removed-attr;部分候选被拒仅重写属性值(属性保留),不记账
if (attr === 'srcset' || attr === 'imagesrcset') { if (attr === 'srcset' || attr === 'imagesrcset') {
const candidates = value.split(',').map((part) => part.trim().split(/\s+/)[0] ?? '') try {
if (candidates.length > 0 && candidates.every((url) => url !== '' && isNaughtyUrl(url, schemes))) { const hasSurvivor = parseSrcset(value).some(
(candidate) => !isNaughtyUrl(candidate.url, schemes),
)
if (!hasSurvivor) diffs.push({ type: 'removed-attr', tag, attr, excerpt })
} catch {
diffs.push({ type: 'removed-attr', tag, attr, excerpt }) diffs.push({ type: 'removed-attr', tag, attr, excerpt })
} }
} }
+12
View File
@@ -0,0 +1,12 @@
// launder 无官方类型(@types/launder 不存在)。本包仅消费其静态导出的 naughtyHref
// ——sanitize-html 2.18 同样以解构方式消费(见其 index.js 顶部
// `const { naughtyHref: launderNaughtyHref } = require('launder')`),属公开用法。
// 签名按 launder@1.7.2 源码核对:cleanHref(剥 \x00-\x20 与 HTML 注释——浏览器
// 忽略它们,常见 scheme 走私手法)→ scheme 前缀判定;options 两键可选,
// allowedSchemes 缺省值为 launder 自带默认表(本包始终显式传入,不依赖缺省)。
declare module 'launder' {
export function naughtyHref(
href: string,
options?: { allowedSchemes?: readonly string[]; allowProtocolRelative?: boolean },
): boolean
}
@@ -4,9 +4,14 @@
import { createReadStream } from 'node:fs' import { createReadStream } from 'node:fs'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest' 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 { 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)) 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:边界输入', () => { describe('sanitizeBodyHtml:边界输入', () => {
it('空串 → 空串、零 diff', () => { it('空串 → 空串、零 diff', () => {
expect(sanitizeBodyHtml('')).toStrictEqual({ html: '', diffs: [] }) expect(sanitizeBodyHtml('')).toStrictEqual({ html: '', diffs: [] })
@@ -222,11 +293,31 @@ describe('SANITIZE_CONFIG:白名单冻结与钉死', () => {
expect(SANITIZE_CONFIG.allowedSchemes).toStrictEqual(['http', 'https', 'mailto', 'tel']) expect(SANITIZE_CONFIG.allowedSchemes).toStrictEqual(['http', 'https', 'mailto', 'tel'])
expect(SANITIZE_CONFIG.allowProtocolRelative).toBe(true) expect(SANITIZE_CONFIG.allowProtocolRelative).toBe(true)
}) })
})
it('仪表化不影响输出:sanitizeHtml(x, SANITIZE_CONFIG) 与 sanitizeBodyHtml(x).html 逐字节一致', () => { describe('仪表化等价(对抗样本参数化)', () => {
const dirty = // 覆盖走私变形(实体/控制字符/NULL/URL 注释)、解析边界(未引号/重复属性/
'<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">' // 混合大小写/深嵌套)与 srcset 形态(data: 逗号内嵌/全恶/不可解析)——这些正是
expect(sanitizeHtml(dirty, { ...SANITIZE_CONFIG })).toBe(sanitizeBodyHtml(dirty).html) // before/after 对比会误报、粗切镜像会漏报的形态
expect(sanitizeHtml(MINI_101_BODY, { ...SANITIZE_CONFIG })).toBe(sanitizeBodyHtml(MINI_101_BODY).html) 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 }))
}) })
}) })
+14
View File
@@ -66,6 +66,12 @@ importers:
'@sf/contracts': '@sf/contracts':
specifier: workspace:* specifier: workspace:*
version: link:../contracts version: link:../contracts
launder:
specifier: ^1.7.2
version: 1.7.2
parse-srcset:
specifier: ^1.0.2
version: 1.0.2
sanitize-html: sanitize-html:
specifier: ^2 specifier: ^2
version: 2.18.1 version: 2.18.1
@@ -73,6 +79,9 @@ importers:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
devDependencies: devDependencies:
'@types/parse-srcset':
specifier: ^1.0.0
version: 1.0.0
'@types/sanitize-html': '@types/sanitize-html':
specifier: ^2.16.2 specifier: ^2.16.2
version: 2.16.2 version: 2.16.2
@@ -924,6 +933,9 @@ packages:
'@types/node@22.20.5': '@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
'@types/parse-srcset@1.0.0':
resolution: {integrity: sha512-5ehc1s3aIQJ05ZVAmYoqDUN8zvlrIZvRUfYP+2bhGNzrY5RQBeJS9JO9jpkVm/vSOW/MkRYLArS/fhXY5cgXPg==}
'@types/sanitize-html@2.16.2': '@types/sanitize-html@2.16.2':
resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==} resolution: {integrity: sha512-kuAbgDCFv418flrp3z4o/4ufLA/5ovmXRH0L419iZCPGO7w00znjeDI5Wy/KZWNIBpDQc7hucCK+J5almB6FyA==}
@@ -2279,6 +2291,8 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/parse-srcset@1.0.0': {}
'@types/sanitize-html@2.16.2': '@types/sanitize-html@2.16.2':
dependencies: dependencies:
htmlparser2: 12.0.0 htmlparser2: 12.0.0