DOMPurify.sanitize() 必须在服务端和前端各执行一次,前端仅为体验优化,服务端才是唯一可信环节,需严格配置禁用危险标签、限制URL协议、校验a标签href,并优先采用结构化数据存储而非原始HTML。

DOMPurify.sanitize() 必须在服务端和前端各跑一次
前端调用 DOMPurify.sanitize() 只是用户体验层的“保险丝”,不是安全闸门。攻击者绕过前端 JS 非常容易:禁用 JS、直接 POST 原始 HTML、用 curl 模拟请求——所有这些都会让前端净化形同虚设。
服务端才是唯一可信环节,必须对入库前的 HTML 字符串做二次净化。哪怕前端已调用过 DOMPurify.sanitize(),服务端仍要再走一遍,且配置需更严格:
- 显式禁用危险标签:
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'style'] - URL 协议白名单只留
['http', 'https', 'mailto'],拒绝javascript:、data:、vbscript: - 对
a标签的href属性单独校验:DOMPurify.isValidAttribute('a', 'href', value),防止编码绕过(如javascript:) - 入库值必须是净化后结果,而非原始输入
富文本粘贴时就该拦截,别等插入 DOM 再清洗
用户从 Word、微信或网页复制内容进编辑器,浏览器默认会把杂乱 HTML 直接塞进 contenteditable 区域。此时 DOM 已污染,后续任何基于 innerHTML 的清洗都晚了——onerror 可能已触发,<script></script> 可能已执行。
正确做法是在 paste 事件里截断并重写:
立即学习“前端免费学习笔记(深入)”;
- 立即
event.preventDefault() - 用
event.clipboardData.getData('text/html')拿原始 HTML 字符串 - 用
new DOMParser().parseFromString(html, 'text/html')解析成 Document 对象 - 遍历节点,只保留白名单标签(
p、h1–h6、ul、ol、li、img),移除所有style、class、data-*属性 - 用
Range+DocumentFragment插入清洗后的内容,保证光标位置不跳
v-html 或 dangerouslySetInnerHTML 渲染前必须校验变量类型与内容长度
这两个 API 不会报错,但会静默失败:传入 null、undefined、空字符串或含控制字符(如 \u0000)的 HTML,结果是空白或解析异常,而不是提示你哪里错了。
实际渲染前应加轻量校验:
- 检查变量是否为字符串:
typeof html === 'string' && html.trim().length > 0 - 过滤掉 NUL 字符:
html.replace(/\u0000/g, '') - 长度超限(如 > 50KB)时直接拒用,防 DoS 或内存溢出
- 若使用 Vue 的
ElNotification等组件,确认同时设置了dangerouslyUseHTMLString: true,否则<b>hello</b>会原样显示为文本
富文本字段入库格式优先选结构化数据,非 HTML
存原始 HTML 是技术债加速器:它让搜索失效、SEO 失效、移动端适配困难、diff 对比失真、SSR 渲染不一致。更麻烦的是,每次读取都要重新净化,性能损耗不可忽视。
推荐路径是「输入即结构化」:
- 用 Quill 导出
Delta,用 TipTap/Slate 导出 JSON,用 CKEditor5 导出editor.getData({ trim: 'both' })返回的语义化结构 - 服务端将结构化数据转成安全 HTML(用
sanitize-html或bleach.clean()),再存库或缓存 - 前端渲染时,仍用
DOMPurify.sanitize()过一遍——因为缓存可能被污染,或 CDN 返回了篡改内容 - 仅当历史数据迁移或第三方系统对接必须用 HTML 时,才接受原始 HTML,且必须带签名或哈希校验
|safe 就足以废掉所有前端努力**。只要变量来源含用户输入,|safe 就是高危操作——它绕过了模板引擎默认转义,等于主动交出控制权。



















