
1. 项目概述富文本编辑器的安全困境如果你负责过带用户发布功能的Web应用比如论坛、博客后台或者在线文档系统那你一定和富文本编辑器打过交道。这东西用起来是真方便用户能像在Word里一样排版、加粗、贴图所见即所得。但方便的背后往往藏着巨大的安全隐患。我见过太多项目前端用着TinyMCE、CKEditor这些大牌编辑器后端却直接innerHTML或者v-html一把梭把用户提交的HTML内容原封不动地渲染到页面上。这无异于在自家网站上给攻击者开了一扇畅通无阻的后门。这个问题的核心就是跨站脚本攻击也就是我们常说的XSS。富文本编辑器允许用户输入HTML而HTML里可以夹带script、onerror这类能执行JavaScript代码的东西。一旦后端没有做好过滤攻击者提交一段恶意脚本其他用户浏览页面时脚本就会在他们的浏览器里执行。轻则弹个烦人的广告窗重则窃取登录Cookie、篡改页面内容、进行钓鱼攻击后果不堪设想。你可能觉得我用的是Vue/React框架不是有内置的转义吗没错{{ }}插值和React.createElement默认是安全的它们会把内容当作文本处理。但富文本内容恰恰需要被解析成HTML节点来展示格式这就逼着你不得不使用v-html或dangerouslySetInnerHTML这类“危险”的API。框架的安全屏障在这里被主动绕过了安全的重担完全落在了开发者对输入内容的净化上。所以今天我们不谈空洞的理论就聚焦一个实战问题当你的Vue/React应用集成了富文本编辑器用户提交的内容在后端存储后前端该如何安全地渲染答案的核心就是一个叫做DOMPurify的库。接下来我会带你彻底搞懂富文本XSS的陷阱在哪然后手把手演示如何用DOMPurify构建一个固若金汤的防御体系。2. 核心陷阱解析为什么富文本是XSS的重灾区要防御先得理解攻击是怎么发生的。很多人对富文本XSS的理解还停留在“用户输入了scriptalert(1)/script”这种初级阶段以为简单过滤掉script标签就万事大吉。实际上攻击者的手段要狡猾得多。2.1 绕过前端过滤编辑器的“安全假象”大多数现代富文本编辑器如TinyMCE、Quill确实内置了一些前端过滤机制。比如你在编辑器里直接输入scriptalert(xss)/script并提交可能发现脚本并没有执行。这是因为编辑器在将内容转换为HTML时可能对script标签进行了HTML实体编码变成了lt;scriptgt;alert(xss)lt;/scriptgt;这样浏览器就会把它当作普通文本显示。但这只是一种非常脆弱的前端防护绝对不能作为唯一的安全依赖。原因有三攻击者不通过编辑器界面攻击完全可以通过工具直接发送HTTP请求到后端接口Payload直接放在请求体里完全绕过富文本编辑器的前端JavaScript代码。编辑器配置可能被修改有些编辑器允许通过配置关闭安全过滤或者存在未知的安全绕过漏洞。过滤规则可能不完整编辑器可能只防了script但XSS的载体远不止于此。2.2 五花八门的XSS Payload载体script标签只是最直白的一种。一个有经验的攻击者会尝试各种HTML属性和标签来执行代码。比如事件处理器利用onload、onerror、onmouseover等属性。!-- 图片加载错误时执行 -- img srcx onerroralert(1) !-- 鼠标悬停时执行 -- svg onloadalert(1)/svgJavaScript伪协议在a标签的href属性中。a hrefjavascript:alert(document.cookie)点击领奖/a样式中的表达式旧版IE支持的CSS表达式或其他可能被执行的样式。div stylebackground-image: url(javascript:alert(1))/div模糊大小写或变形用来绕过简单的字符串匹配过滤。ScRiPtalert(1)/ScRiPt img srcx oneRroralert(1)2.3 后端过滤的常见误区意识到前端不可靠开发者会把防线移到后端但这里也布满陷阱。误区一简单的字符串替换或正则过滤比如用preg_replace把script替换成空字符串。这很容易被“双写绕过”。// 后端过滤代码 $message preg_replace(/script/i, , $_POST[message]); // 攻击者输入 scripscripttalert(1)/scrscriptipt // 过滤后中间的script被移除两边的字符又拼成了新的script scriptalert(1)/script误区二错误使用htmlspecialcharshtmlspecialchars()是PHP中用于转义HTML特殊字符的函数但它有多个参数默认模式ENT_COMPAT只转义双引号不转义单引号。如果输出上下文是单引号包裹的属性值就会出问题。// 后端代码用户输入被插入到单引号属性中 $userInput $_GET[input]; // 假设输入是: onclickalert(1) $html a href. htmlspecialchars($userInput) .链接/a; // 输出结果单引号未被转义导致属性提前闭合并注入新的事件 a href onclickalert(1)链接/a正确的用法是指定ENT_QUOTES标志同时转义单双引号htmlspecialchars($input, ENT_QUOTES, UTF-8)。误区三只在存储时过滤一次安全是一个链条。理想的情况是在输入时入库前进行严格的净化在输出时渲染前根据上下文再进行一次适当的编码。如果只在存储时做了一次宽松的过滤而输出时直接信任了数据库中的数据当过滤规则更新或存在漏洞时历史数据就可能成为攻击入口。3. 防御基石DOMPurify的工作原理与核心优势面对如此复杂的攻击面自己手写过滤正则无疑是吃力不讨好的而且极易遗漏。我们需要一个专门为此而生的工具——DOMPurify。3.1 什么是DOMPurifyDOMPurify是一个用JavaScript编写的、超快且极度宽容的DOM-only XSS过滤器。它的核心设计哲学是**“白名单”机制**。不同于用黑名单去拦截已知的坏东西永远追不上新的攻击手法白名单只允许已知的好东西通过。简单来说DOMPurify会解析你输入的HTML字符串。遍历整个DOM树。根据一个预定义的、非常严格的白名单检查每个元素、属性。移除所有不在白名单上的东西包括元素、属性、事件处理器等。返回一个纯净、安全的HTML字符串。这个白名单是经过安全专家精心维护的涵盖了富文本编辑场景下所有“安全且必要”的标签和属性比如p,b,img src,a href但会剔除script,onclick,href中的javascript:等危险内容。3.2 为何选择DOMPurify市面上也有其他HTML净化库比如PHP的HTMLPurifier。DOMPurify的优势在于纯前端/Node.js可用它可以在浏览器中运行这意味着你可以在用户提交内容到后端之前先在客户端做一次初步净化减轻服务器压力。更重要的是可以在前端渲染时使用v-html前再做一次最终的、可靠的净化实现“输出编码”的安全原则。速度快、体积小压缩后仅约10KB对性能影响极小。高度可配置你可以自定义白名单允许或禁止特定的标签和属性以适应不同的业务场景。例如你可以允许class和style属性以实现更丰富的排版但必须仔细评估其风险。业界标杆被Slack、GitLab等众多大型项目使用经过了严格的实战考验。3.3 核心安全机制剖析DOMPurify的安全不是靠魔法而是靠一系列组合拳DOM解析隔离它使用浏览器自身的DOM解析器或Node.js下的jsdom来解析HTML但这个过程是在一个“沙盒”环境中进行的通常是新创建的document对象或template元素。这确保了净化过程不会意外影响到当前页面的真实DOM。递归遍历与过滤解析生成DOM树后DOMPurify会递归检查每个节点。对于元素检查标签名是否在白名单内对于属性检查属性名、属性值特别是href、src等URL属性会检查协议是否为http:、https:、mailto:等安全协议。处理畸形HTML攻击者经常使用畸形的HTML来尝试绕过解析器。DOMPurify的解析器非常“宽容”能将这些畸形HTML正常化后再进行过滤避免了因解析差异导致的安全漏洞。移除危险内容不仅仅是删除标签对于style标签或属性中的内容、svg中的script元素等都会进行深度检查并清理。4. 实战集成在Vue项目中用DOMPurify净化富文本理论说再多不如一行代码。我们以一个常见的Vue 3 Vite TinyMCE项目为例展示完整的集成流程。4.1 项目初始化与依赖安装首先创建一个Vite项目并安装必要的依赖。npm create vuelatest my-rich-text-app cd my-rich-text-app npm install # 安装富文本编辑器 TinyMCE 和 Vue 封装 npm install tinymce tinymce/tinymce-vue # 安装安全核心 DOMPurify npm install dompurify # 如果需要服务端渲染(SSR)或在Node.js中使用还需要安装 jsdom npm install jsdom4.2 封装安全的富文本显示组件这是最关键的环节。我们将创建一个SafeRichText.vue组件它接收一个原始的HTML字符串prop使用DOMPurify净化后再通过v-html安全地渲染。!-- components/SafeRichText.vue -- template div classsafe-rich-text v-htmlpurifiedHtml/div /template script setup import { ref, watch, onMounted, onUnmounted } from vue; import DOMPurify from dompurify; const props defineProps({ html: { type: String, default: , }, // 可选的配置项传递给DOMPurify config: { type: Object, default: () ({}), }, }); const purifiedHtml ref(); // 净化函数 const sanitize (dirtyHtml) { if (!dirtyHtml) return ; // 基础配置使用默认白名单这是一个非常严格的集合 const baseConfig { // 允许添加target_blank的链接自动获得relnoopener noreferrer防止tabnabbing攻击 ADD_ATTR: [target], // 允许data-*属性方便自定义功能但需谨慎 // ADD_DATA_URI_TAGS: [a, img], // 允许data: URI在特定标签通常不建议 }; // 合并传入的配置 const finalConfig { ...baseConfig, ...props.config }; try { // 核心净化调用 return DOMPurify.sanitize(dirtyHtml, finalConfig); } catch (error) { console.error(DOMPurify sanitization error:, error); // 净化失败时返回转义后的文本确保安全 return escapeHtml(dirtyHtml); } }; // 一个简单的HTML转义函数作为兜底 const escapeHtml (text) { const div document.createElement(div); div.textContent text; return div.innerHTML; }; // 监听html prop的变化重新净化 watch(() props.html, (newHtml) { purifiedHtml.value sanitize(newHtml); }, { immediate: true }); // 组件挂载时执行一次净化 onMounted(() { purifiedHtml.value sanitize(props.html); }); /script style scoped .safe-rich-text :deep(img) { max-width: 100%; height: auto; } .safe-rich-text :deep(table) { border-collapse: collapse; } /* 可以在这里添加其他全局样式安全地影响净化后的内容 */ /style关键提示DOMPurify.sanitize()方法返回的是一个字符串而不是DOM节点。Vue的v-html指令正是接收一个字符串并将其作为HTML解析。净化过程发生在字符串被赋予v-html之前从而切断了XSS的路径。4.3 集成TinyMCE编辑器并传递内容接下来我们创建一个使用TinyMCE的编辑器组件并在提交时将内容传递给父组件或发送到后端。!-- components/RichTextEditor.vue -- template div Editor v-modeleditorContent :initeditorConfig api-keyyour-api-key !-- 如果是Cloud版本需要 -- / button clickhandleSubmit提交内容/button div预览/div !-- 使用我们封装的安全组件来预览 -- SafeRichText :htmleditorContent / /div /template script setup import { ref } from vue; import Editor from tinymce/tinymce-vue; // 引入TinyMCE核心JS文件通常放在public目录或通过CDN引入 // 这里假设通过CDN引入在index.html中已添加 script srchttps://cdn.tiny.cloud/1/your-api-key/tinymce/6/tinymce.min.js/script // 如果本地部署需要更复杂的资源管理此处不展开 const editorContent ref(p这里是初始内容.../p); const editorConfig ref({ height: 400, menubar: true, plugins: [ advlist, autolink, lists, link, image, charmap, preview, anchor, searchreplace, visualblocks, code, fullscreen, insertdatetime, media, table, help, wordcount ], toolbar: undo redo | blocks | bold italic backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | removeformat | help, // 重要设置TinyMCE的内容安全策略虽然不能完全依赖但可增加一层防护 content_security_policy: script-src none; object-src none;, // 自定义图片上传处理等... }); const emit defineEmits([submit]); const handleSubmit () { console.log(提交的内容原始HTML:, editorContent.value); // 在实际应用中这里应该将 editorContent.value 发送到后端API // 注意后端也必须进行净化处理前端净化可以被绕过。 emit(submit, editorContent.value); }; /script4.4 在后端进行二次净化Node.js示例绝对不要只依赖前端的DOMPurify。攻击者可以绕过浏览器直接调用API。因此后端在接收数据、存入数据库之前必须进行同样严格的净化。在Node.js如Express后端中我们可以使用jsdom来创建一个独立的DOM环境然后在这个环境中使用DOMPurify。// server/middleware/sanitizeHtml.js import { JSDOM } from jsdom; import createDOMPurify from dompurify; // 使用jsdom创建一个window对象DOMPurify需要它 const window new JSDOM().window; const DOMPurify createDOMPurify(window); // 可复用的净化配置应与前端尽量保持一致 const sanitizeConfig { ALLOWED_TAGS: [ p, br, b, i, u, strong, em, strike, sub, sup, h1, h2, h3, h4, h5, h6, ul, ol, li, a, img, blockquote, code, pre, table, thead, tbody, tr, th, td, ], ALLOWED_ATTR: [ href, target, title, // for a src, alt, title, width, height, // for img class, style, // 谨慎允许需评估风险 colspan, rowspan, // for td, th ], // 强制所有链接的target_blank具有安全属性 ADD_ATTR: [target], // 净化URL属性只允许http, https, mailto, tel等 ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel):|[^a-z]|[a-z.\-](?:[^a-z.\-:]|$))/i, }; /** * 中间件净化请求体中的html字段 */ export const sanitizeHtmlMiddleware (req, res, next) { if (req.body typeof req.body.html string) { try { req.body.originalHtml req.body.html; // 可选保存原始内容用于审计 req.body.html DOMPurify.sanitize(req.body.html, sanitizeConfig); } catch (error) { console.error(Server-side DOMPurify error:, error); // 净化失败可以返回400错误或者替换为一个安全的错误提示 req.body.html p内容包含不安全代码已被过滤。/p; } } next(); }; // 在路由中使用 // app.post(/api/content, sanitizeHtmlMiddleware, (req, res) { // const safeHtml req.body.html; // 已经是净化后的内容 // // 存入数据库... // });实操心得前后端的净化配置白名单应尽可能保持一致以避免出现前端允许的内容被后端过滤掉影响用户体验或者后端允许的内容前端无法安全渲染的情况。可以将配置抽离成一个共享的配置文件。5. 高级配置与自定义白名单DOMPurify的默认白名单非常严格可能过滤掉一些你业务中需要的合法标签或属性比如iframe用于嵌入视频style属性用于自定义颜色。这时就需要自定义配置。5.1 常用配置选项解析import DOMPurify from dompurify; const customConfig { // ALLOWED_TAGS: 定义允许的HTML标签数组。不设置则使用默认严格白名单。 ALLOWED_TAGS: [p, h1, h2, span, div, a, img, iframe, strong, em], // ALLOWED_ATTR: 定义允许的HTML属性数组。 ALLOWED_ATTR: [href, src, alt, title, class, style, allowfullscreen, frameborder], // 对于特定标签允许额外的属性。格式{ ‘标签名’: [‘属性1’, ‘属性2’] } ADD_TAGS: [iframe], ADD_ATTR: [allow, allowfullscreen, frameborder, scrolling], // 允许data-*属性。设置为true会允许所有data-*属性有风险。 ALLOW_DATA_ATTR: false, // 允许的URI协议。默认允许http, https, ftp, mailto, tel等。 // 可以通过ALLOWED_URI_REGEXP自定义正则或使用ALLOW_UNKNOWN_PROTOCOLS: false默认来禁止未知协议。 // 强制为特定标签添加属性。例如为所有target_blank的链接添加relnoopener FORCE_ATTR_VALUE: [ { a[target_blank]: { rel: noopener noreferrer } } ], // 是否返回一个DOM节点而非字符串。对于Vue的v-html我们需要字符串所以用默认值false。 RETURN_DOM: false, RETURN_DOM_FRAGMENT: false, RETURN_DOM_IMPORT: false, // 净化后是否保留原始DOM节点上的事件监听器等。必须为false以确保安全。 KEEP_CONTENT: false, }; const dirtyHtml p正常文本 iframe srchttps://www.youtube.com/embed/xxx width560 height315 frameborder0 allowfullscreen/iframe scriptalert(xss)/script/p; const cleanHtml DOMPurify.sanitize(dirtyHtml, customConfig); console.log(cleanHtml); // 输出: p正常文本 iframe srchttps://www.youtube.com/embed/xxx width560 height315 frameborder0 allowfullscreen/iframe /p // script标签被移除iframe被保留因为我们在白名单中加入了iframe及其属性。5.2 处理富媒体嵌入Iframe允许iframe是高风险操作因为它可以加载任意第三方页面。必须严格限制其src的域名。const config { ALLOWED_TAGS: [p, iframe, ...], ALLOWED_ATTR: [src, width, height, frameborder, allowfullscreen], // 自定义URL净化器 SANITIZE_DOM: false, // 设为false以使用自定义钩子 // 使用钩子在净化过程中进行更细粒度的控制 }; // 更精细的控制通常需要使用DOMPurify的钩子hooks例如检查iframe的src是否在白名单域名内。5.3 样式Style属性的风险与处理允许style属性或style标签意味着允许用户自定义CSS这可能带来CSS注入风险如通过background-image: url(javascript:...)在旧浏览器中执行代码或通过expression()在IE中执行。此外恶意CSS还可以进行点击劫持或窃取数据如通过属性选择器窃取CSRF Token。建议尽量避免除非绝对必要否则不在白名单中添加style。如果必须允许考虑使用一个严格的CSS解析器如cssfilter、sanitize-css对style属性的值进行二次过滤只允许安全的CSS属性如color,font-size,text-align和值。提供替代方案通过编辑器工具栏提供预设的样式类如.text-red,.text-center然后只允许class属性并在网站样式表中预定义这些类的安全样式。6. 构建纵深防御体系DOMPurify不是银弹DOMPurify是强大的一环但真正的安全需要多层防御。6.1 内容安全策略CSP——最后的防线CSP是一个HTTP响应头用来告诉浏览器哪些外部资源可以加载和执行。即使恶意脚本通过了DOMPurify的过滤并成功注入到HTML中一个严格的CSP也可以阻止其执行。一个针对富文本内容的推荐CSP配置示例Content-Security-Policy: default-src self; script-src self; style-src self unsafe-inline; img-src self data: https:; font-src self; connect-src self; media-src self; frame-src self https://www.youtube.com https://player.vimeo.com;script-src self: 只允许执行来自同源的脚本。禁止内联脚本如scriptalert(1)/script和onclick...这是防御XSS最有效的一招。DOMPurify已经移除了这些但CSP提供了额外的保障。style-src self unsafe-inline: 允许同源样式表和内联样式。因为富文本内容通常包含内联样式stylecolor: red;所以需要unsafe-inline。这削弱了CSP对CSS注入的防护因此前面对style属性的严格限制就显得尤为重要。frame-src ...: 明确列出允许嵌入的iframe来源如YouTube, Vimeo。这比使用*或self安全得多。注意CSP的配置需要根据你的具体业务需求仔细调整错误的配置可能导致网站功能损坏。可以通过Content-Security-Policy-Report-Only头在报告模式下先进行测试。6.2 设置安全的Cookie属性即使发生了XSS我们也要设法减少损失。为会话Cookie设置HttpOnly和Secure属性。HttpOnly: 阻止JavaScript通过document.cookie访问Cookie使得攻击者即使注入脚本也无法直接窃取会话令牌。Secure: 强制Cookie仅通过HTTPS传输。SameSite: 设置为Lax或Strict可以防止跨站请求伪造攻击。6.3 输入验证与输出编码输入验证在接收富文本内容的同时对并存的普通文本字段如标题、作者名进行严格的类型、长度和格式验证。输出编码对于非富文本内容在渲染到页面时务必使用框架的默认文本插值如Vue的{{ }}或对应的编码函数如encodeURIComponent用于URLtextContent用于DOM永远不要对非受信的非HTML内容使用v-html。6.4 定期更新与安全审计保持DOMPurify更新关注其版本更新及时修复可能存在的安全漏洞。代码审计定期审查使用v-html、innerHTML、dangerouslySetInnerHTML的地方确保其输入都经过了净化。安全测试将XSS测试纳入自动化测试流程可以使用ZAP、Burp Suite等工具进行主动扫描。7. 常见问题与排查技巧实录在实际集成DOMPurify的过程中你肯定会遇到一些坑。以下是我总结的几个典型问题及解决方案。7.1 内容被过度过滤样式丢失问题用户提交的包含class或style的富文本渲染后样式全无。原因DOMPurify的默认白名单不允许class和style属性。解决在配置中显式添加这些属性到ALLOWED_ATTR中。但务必阅读第5.3节评估允许style带来的风险。对于class确保其值不会用于恶意目的如注入JavaScript。const config { ALLOWED_ATTR: [class, style, href, src, alt, ...], };7.2 在服务端渲染SSR或Node.js环境中报错问题在Nuxt.js的服务器端或纯Node.js环境中运行import DOMPurify from dompurify时报错提示window未定义。原因DOMPurify是浏览器库依赖window对象。解决使用jsdom来模拟浏览器环境。// 在Node.js环境中 import { JSDOM } from jsdom; import createDOMPurify from dompurify; const window new JSDOM().window; const DOMPurify createDOMPurify(window); const clean DOMPurify.sanitize(dirtyHtml);在Nuxt.js中你需要确保这段代码只在服务端执行或者使用条件导入。7.3 与特定UI框架或库冲突问题净化后的内容中使用了一些框架特有的语法如Vue的click、React的onClick这些属性被DOMPurify过滤掉了导致功能失效。原因DOMPurify的白名单只包含标准的HTML属性和事件如onclick但通常也会被过滤不包含任何前端框架的特定语法。解决这是一个关键认知点DOMPurify净化的是将要被浏览器原生解析的HTML字符串。任何框架的指令和事件绑定都应该在净化之后由框架本身在创建VNode/React元素时处理。正确的模式是从后端获取已净化的、纯HTML字符串。在Vue/React组件中将这个字符串通过v-html/dangerouslySetInnerHTML渲染到一个“哑巴”容器元素中。这个容器元素内部的任何点击等交互如果需要应该通过事件委托在容器父级上监听或者使用MutationObserver等复杂方式动态绑定。通常富文本内容本身应该是只读的展示区域不包含复杂的交互逻辑。如果需要交互应通过其他UI组件实现而非依赖净化后的HTML中的属性。7.4 性能考量与优化问题净化非常长的HTML文档如整本书时可能感到有性能延迟。解决分片处理对于极长的内容可以考虑在后端分片净化。缓存净化结果如果相同的不安全内容被多次渲染例如一篇热门文章可以考虑缓存其净化后的安全版本。Web Worker在前端可以将净化任务放到Web Worker中避免阻塞主线程。DOMPurify体积小很适合这种方式。// sanitizer.worker.js import DOMPurify from dompurify; self.onmessage (event) { const { html, config } event.data; const clean DOMPurify.sanitize(html, config); self.postMessage(clean); };7.5 调试查看DOMPurify到底过滤了什么有时你需要知道为什么某些内容不见了。DOMPurify在开发模式下会在控制台输出被移除的标记信息。确保你的构建没有将process.env.NODE_ENV设置为production以查看这些日志。你也可以在配置中设置RETURN_DOM为true然后比较净化前后DOM树的差异但这更复杂一些。最简单的方法是在调用sanitize后比较输入和输出的字符串长度和结构。最后安全是一个持续的过程而不是一个一劳永逸的开关。将DOMPurify集成到你的富文本处理流程中结合CSP、安全的Cookie策略和良好的开发习惯才能为你和你的用户构建起一道有效的安全屏障。每次当你写下v-html时都问自己一句“这里的输入我净化了吗”