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:
@@ -18,6 +18,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sf/contracts": "workspace:*",
|
||||
"sanitize-html": "^2",
|
||||
"saxes": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sanitize-html": "^2.16.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './limits.js'
|
||||
export * from './parse/php-unserialize.js'
|
||||
export * from './parse/wxr-stream.js'
|
||||
export * from './map/item-to-row.js'
|
||||
export * from './sanitize/html.js'
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// WordPress 正文 HTML 白名单清洗(Plan 2 Task 5,ADR-009):清洗本体交 sanitize-html
|
||||
// (锁 ^2,MIT),本模块只做两件事——集中导出白名单配置 {@link SANITIZE_CONFIG},
|
||||
// 与"清洗 + 差异收集"纯函数 {@link sanitizeBodyHtml}。原文永远保留在 Source Snapshot
|
||||
// 是调用方的职责(ADR-009),本模块不碰存储;白名单升级后可对原文重跑本函数。
|
||||
//
|
||||
// 差异收集方案取舍(选定 onOpenTag 观察钩子,JSDoc 钉死):
|
||||
// - sanitize-html 无原生"移除回调"。候选方案:
|
||||
// a) onOpenTag(name, attribs)——官方类型支持的观察钩子,对**每个**开标签事件触发
|
||||
// (含未批准标签),携带**未过滤**的原始属性表;纯观察、零输出影响(测试钉死
|
||||
// 与无仪表化运行的字节等价)。
|
||||
// b) transformTags——同样先于白名单判定触发,但具变换语义(须原样回显
|
||||
// {tagName, attribs}),仪表化借道变换机制过重。
|
||||
// c) exclusiveFilter——只见"存活"元素(关闭时回调、属性已过滤),看不到移除。
|
||||
// d) before/after 字符串对比——等价变形(属性重排序/自闭合规范化/实体转义)
|
||||
// 制造假差异,不可靠。
|
||||
// e) 正则预扫描——非 tokenizer 精度(注释/引号内 '>'/大小式变形),仅可作为
|
||||
// 下策。onOpenTag 复用 sanitize-html 自身解析器(htmlparser2)的事件流,
|
||||
// 精度与清洗本体同源,故选 a。
|
||||
// - 镜像逻辑(判断"什么会被拒")从 SANITIZE_CONFIG 单源推导,不另立策略表:
|
||||
// 标签 ∈ allowedTags?属性 ∈ allowedAttributes[tag] ∪ allowedAttributes['*']?
|
||||
// URL 属性(allowedSchemesAppliedToAttributes)协议 ∈ allowedSchemes
|
||||
// (含 allowedSchemesByTag 覆盖与协议相对 URL 规则,语义镜像 launder.naughtyHref)。
|
||||
// - 不镜像的清洗路径( exotic,报告中不体现;清洗本体不受影响):
|
||||
// 属性名合法性校验、空值非布尔属性删除、meta refresh content、SVG animate 名单、
|
||||
// allowedClasses 过滤、srcset 部分候选重写(仅"全部候选被拒→整属性删除"记账,
|
||||
// 候选切分为近似逗号分割)、空 srcset 删除。
|
||||
// - 记账粒度:按**白名单拒绝的开标签事件**逐次记录(文档序;属性按声明序)——
|
||||
// 不按"最终 DOM 差异"去重:discard 剥壳语义下未批准容器的子内容会被解包保留,
|
||||
// 其属性过滤真实发生,同样值得留痕;宁多报不漏报(审计定位用途)。
|
||||
// 顺序确定 → 同输入必得同 diffs(纯函数,无共享状态,测试钉死)。
|
||||
//
|
||||
// 白名单范围决策(冻结;改动须评审,测试钉死值域):
|
||||
// - h1 不入白名单:标题层级归主题层(文章标题由主题渲染 h1),正文从 h2 起;
|
||||
// 出现 h1 会进 diff(removed-tag),文本按 discard 语义保留。
|
||||
// - 布局语义标签(section/article/header/footer/nav/aside/main)不入白名单:
|
||||
// discard 剥壳保留子内容,布局由阶段 3 主题层重建,不透传源站布局。
|
||||
// - script/style/iframe/form/svg/math 等一律拒绝;iframe 无宿主白名单通道——
|
||||
// "allowIframe: false"的等价物就是 allowedTags 缺席(本库无此独立开关)。
|
||||
// - style 属性与 <style> 标签均拒绝(ADR-009:内联样式是风险面,清洗后进产出的
|
||||
// 内容不得携带 CSS;未来若放开须走 allowedStyles 显式子集评审)。
|
||||
// - class/id 透传:WP 对齐类(alignleft 等)与锚点依赖之;class 值是惰性文本,
|
||||
// 安全边界在标签/属性白名单,不 class 名单(阶段 3 主题自行取舍是否消费)。
|
||||
// - **追踪像素策略(本切片钉死)**:外链 <img>(含 1x1 追踪像素形态)一律保留——
|
||||
// 移除判定只看白名单(script/事件属性/危险协议/未批准标签),不按尺寸/域名猜测
|
||||
// 意图。外链资源清单属 Task 6 的"远程依赖报告"范畴,路径重写(含媒体本地化)属
|
||||
// 阶段 3;本模块不越权。
|
||||
|
||||
import sanitizeHtml, { type Attributes, type IOptions } from 'sanitize-html'
|
||||
|
||||
/** 单条清洗差异:一次被白名单拒绝的标签/属性出现(审计与报告的最小记账单元) */
|
||||
export interface SanitizeDiff {
|
||||
/** 'removed-tag':标签未批准(属性不重复记账);'removed-attr':标签保留但属性被拒 */
|
||||
type: 'removed-tag' | 'removed-attr'
|
||||
/** 小写标签名(htmlparser2 归一) */
|
||||
tag: string
|
||||
/** 仅 'removed-attr':被拒属性名(小写)。'removed-tag' 时无此键 */
|
||||
attr?: string
|
||||
/** 被移除内容的重构摘要:removed-tag=开标签重构 `<tag a="v">`;removed-attr=属性序列化 `a="v"`;控制字符已转义,截前 80 字符 */
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
/** sanitizeBodyHtml 返回:清洗后 HTML + 差异清单(文档序) */
|
||||
export interface SanitizeResult {
|
||||
html: string
|
||||
diffs: SanitizeDiff[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 白名单清洗配置(集中导出,后续阶段可复用于 preview CSP 对齐;冻结浅层)。
|
||||
* 镜像判定(diff 收集)与清洗本体共用本配置——单源,改这里两边同步。
|
||||
* allowedSchemesAppliedToAttributes 显式钉死 ['href','src','cite'](本白名单内
|
||||
* 仅这三类 URL 属性;显式化使镜像判定不依赖库默认值)。
|
||||
*/
|
||||
export const SANITIZE_CONFIG: Readonly<IOptions> = Object.freeze({
|
||||
// 编辑性内容标签(WP 正文实况:块编辑器的 figure/div/span、表格、列表、引用、
|
||||
// 代码、详情折叠等);h1/布局标签/style/iframe 的排除理由见模块头
|
||||
allowedTags: [
|
||||
'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',
|
||||
],
|
||||
allowedAttributes: {
|
||||
'*': ['class', 'dir', 'id', 'lang', 'title'],
|
||||
a: ['href', 'name', 'rel', 'target'],
|
||||
img: ['alt', 'decoding', 'height', 'loading', 'sizes', 'src', 'srcset', 'width'],
|
||||
blockquote: ['cite'],
|
||||
col: ['span'],
|
||||
colgroup: ['span'],
|
||||
del: ['cite', 'datetime'],
|
||||
ins: ['cite', 'datetime'],
|
||||
li: ['value'],
|
||||
ol: ['reversed', 'start', 'type'],
|
||||
q: ['cite'],
|
||||
td: ['colspan', 'headers', 'rowspan', 'scope'],
|
||||
th: ['abbr', 'colspan', 'headers', 'rowspan', 'scope'],
|
||||
time: ['datetime'],
|
||||
details: ['open'],
|
||||
},
|
||||
// 协议白名单:常规 Web + 联系方式(mailto/tel,WP 站点 CTA 常见);
|
||||
// data:/javascript:/vbscript: 等一律拒绝(data: 图片走私亦拒绝,进 diff)
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||
// 协议相对 URL(//cdn.example/x)放行:WP 混合协议遗留常见,浏览器按页面协议解析
|
||||
allowProtocolRelative: true,
|
||||
// URL 属性名单见 JSDoc;其余配置走库默认(discard 剥壳、nonTextTags 连内容删)
|
||||
allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
|
||||
})
|
||||
|
||||
/** excerpt 上限(字符):审计摘要够定位即可,控制报告体积 */
|
||||
const EXCERPT_MAX = 80
|
||||
|
||||
// ---- 镜像判定的派生索引(SANITIZE_CONFIG 冻结 → 模块级一次派生,纯常量视图)----
|
||||
|
||||
// allowedTags 的 false 形态(全拒绝)语义上等于空集——镜像统一为空 Set
|
||||
const TAGS_CFG = SANITIZE_CONFIG.allowedTags
|
||||
const ALLOWED_TAGS: ReadonlySet<string> = new Set(
|
||||
TAGS_CFG === false || TAGS_CFG === undefined ? [] : TAGS_CFG,
|
||||
)
|
||||
|
||||
const ALLOWED_ATTRS = new Map<string, ReadonlySet<string>>()
|
||||
{
|
||||
const configured = SANITIZE_CONFIG.allowedAttributes
|
||||
if (configured !== false && configured !== undefined) {
|
||||
for (const [tag, attrs] of Object.entries(configured)) {
|
||||
// AllowedAttribute 允许 {name, values} 受限值形态;本配置只用字符串形态,
|
||||
// 镜像只认字符串(受限值形态若未来启用,须同步扩展此处)
|
||||
ALLOWED_ATTRS.set(
|
||||
tag,
|
||||
new Set(attrs.filter((a): a is string => typeof a === 'string')),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 属性):
|
||||
* 清理(剥 \x00-\x20 与 HTML 注释——浏览器忽略它们,常用于走私 scheme)后取
|
||||
* scheme 前缀;无 scheme 时仅协议相对 URL 受 allowProtocolRelative 约束。
|
||||
* 镜像而非复用(launder 未导出公开契约):若库升级判定变化,测试对照
|
||||
* (仪表化等价测试 + 协议用例)会暴露漂移。
|
||||
*/
|
||||
function isNaughtyUrl(rawUrl: string, allowedSchemes: readonly string[]): boolean {
|
||||
// 浏览器会忽略 URL 中的控制字符(\x00-\x20,常见 scheme 走私手法,见 OWASP
|
||||
// XSS Filter Evasion Cheat Sheet#Embedded tab)——控制字符正是本清洗的目标,豁免
|
||||
// eslint-disable-next-line no-control-regex
|
||||
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) */
|
||||
function escapeControlChars(text: string): string {
|
||||
// 转义控制字符是本函数职责本身,豁免(同上:字符类里写控制字符是有意为之)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/[\x00-\x1f\x7f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)
|
||||
}
|
||||
|
||||
function makeExcerpt(raw: string): string {
|
||||
return escapeControlChars(raw).slice(0, EXCERPT_MAX)
|
||||
}
|
||||
|
||||
/** 开标签重构(属性原值经 JSON.stringify 序列化——引号/反斜杠/控制字符一并转义) */
|
||||
function openingTagMarkup(tag: string, attribs: Attributes): string {
|
||||
let markup = `<${tag}`
|
||||
for (const [name, value] of Object.entries(attribs)) {
|
||||
markup += ` ${name}=${JSON.stringify(value)}`
|
||||
}
|
||||
return `${markup}>`
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个开标签事件的拒绝记账(onOpenTag 回调体):
|
||||
* 标签被拒 → 只记 removed-tag(其属性随标签消失,不重复记账);
|
||||
* 标签放行 → 逐属性记(未批准 / URL 协议被拒 / srcset 全候选被拒)。
|
||||
*/
|
||||
function recordRejections(diffs: SanitizeDiff[], tag: string, attribs: Attributes): void {
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
diffs.push({ type: 'removed-tag', tag, excerpt: makeExcerpt(openingTagMarkup(tag, attribs)) })
|
||||
return
|
||||
}
|
||||
const tagAttrs = ALLOWED_ATTRS.get(tag) ?? EMPTY_ATTRS
|
||||
const starAttrs = ALLOWED_ATTRS.get('*') ?? EMPTY_ATTRS
|
||||
const schemes = schemesFor(tag)
|
||||
for (const [attr, value] of Object.entries(attribs)) {
|
||||
const excerpt = makeExcerpt(`${attr}=${JSON.stringify(value)}`)
|
||||
if (!tagAttrs.has(attr) && !starAttrs.has(attr)) {
|
||||
diffs.push({ type: 'removed-attr', tag, attr, excerpt })
|
||||
continue
|
||||
}
|
||||
if (SCHEME_ATTRS.has(attr) && isNaughtyUrl(value, schemes)) {
|
||||
diffs.push({ type: 'removed-attr', tag, attr, excerpt })
|
||||
continue
|
||||
}
|
||||
// srcset/imagesrcset 逐候选协议过滤的镜像:全部候选被拒时库会整属性删除(近似
|
||||
// 逗号切分,仅 diff 判定用);部分被拒仅重写不删除,不记账
|
||||
if (attr === 'srcset' || attr === 'imagesrcset') {
|
||||
const candidates = value.split(',').map((part) => part.trim().split(/\s+/)[0] ?? '')
|
||||
if (candidates.length > 0 && candidates.every((url) => url !== '' && isNaughtyUrl(url, schemes))) {
|
||||
diffs.push({ type: 'removed-attr', tag, attr, excerpt })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WordPress 正文 HTML 白名单清洗(纯函数):清洗本体 = sanitize-html + SANITIZE_CONFIG;
|
||||
* 差异 = onOpenTag 观察钩子按文档序收集(方案取舍与记账粒度见模块头)。
|
||||
* 同输入必得同输出与同 diffs;每调用独立 diffs 数组,调用方持有即可安全归档。
|
||||
*/
|
||||
export function sanitizeBodyHtml(html: string): SanitizeResult {
|
||||
const diffs: SanitizeDiff[] = []
|
||||
// 展开副本承载仅本次调用的仪表化钩子(SANITIZE_CONFIG 冻结,库的默认值归一
|
||||
// 写入副本,不触碰导出常量);onOpenTag 为纯观察——输出与无钩子运行字节一致(测试钉死)
|
||||
const cleaned = sanitizeHtml(html, {
|
||||
...SANITIZE_CONFIG,
|
||||
onOpenTag: (name, attribs) => {
|
||||
recordRejections(diffs, name, attribs)
|
||||
},
|
||||
})
|
||||
return { html: cleaned, diffs }
|
||||
}
|
||||
@@ -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