信息安全组扔给我一个任务的时候,我正在翻一个内容平台后台的富文本模块——某内容平台的后台编辑器用的就是 Quill,版本不算旧,但问题单上写得很直接:Quill富文本编辑器HTML导出功能存在XSS漏洞,需要分析影响面、给出修复方案。我当时的第一个反应是,富文本 XSS 这个话题几乎人人都知道,排起来应该不难。结果沿着导出 HTML 这条链路捋了一遍,发现风险点比预想的多不少,而且大部分不在 Quill 自身,而在我们把 HTML 当普通字符串传来传去的那几层。
这篇文章把整个分析过程、复现思路、修复方案以及排查时的经验教训完整记下来,给同样在项目里用 Quill 的团队一个可以照着做的参考。适合正在做后台编辑器的前端同学、负责安全加固的后端同学,以及想系统性补上富文本漏洞的团队。整篇内容不卖关子,直接讲怎么发现问题、怎么验证问题、怎么修掉问题。
1. 漏洞触发场景:问题不是“有没有”,而是“哪一层信任了它”
1.1 项目里 Quill 的接入方式
某内容平台内部有个代号为“模拟项目X”的文档发布系统,管理端一直用 Quill 作为文章类内容的富文本编辑器。接入方式很典型:前端用new Quill('#editor')初始化编辑器,在点击“保存”时调用editor.getHTML()拿到 HTML 字符串,然后 POST 到后端接口落库;在详情页渲染时,前端又把这个字段通过v-html或innerHTML直接注入页面。这几乎是市面上大多数后台系统的标准姿势,团队也一直这么用,没有人觉得有问题。
我拿到代码后做的第一件事,是梳理出所有调用getHTML()或者读取富文本 HTML 字段的地方。结果发现不只有管理后端的保存按钮,还有另外两条链路:一条是详情页的预览区,用来展示编辑结果;另一条是导出 PDF 前的模板拼接,把富文本 HTML 作为一个变量塞进打印模板。也就是说,同一份 HTML 会被复制到至少三个下游消费点。
这里已经是第一个值得警惕的信号:Quill 本身只负责把 Delta 数据转成 HTML,它并不承诺输出安全 HTML。团队把getHTML()的返回值当成“可信的内部格式”,但从安全角度讲,它只是一段未经净化的字符串。问题单上写的“HTML 导出功能存在 XSS 漏洞”,准确说不是 Quill 的导出 API 有致命缺陷,而是使用方在导出之后的所有环节,都没有再做任何一层校验。
1.2 影响面评估:谁在消费导出的 HTML
确认接入方式后,影响面就比较清晰了。这里我用表格整理了排查到的消费方:
| 消费场景 | 数据流向 | 风险等级 |
|---|---|---|
| 管理后台保存 | 编辑器 -> getHTML() -> 后端接口 | 高(恶意内容被持久化) |
| 详情页预览 | 后端字段 -> v-html 注入 | 高(直接执行) |
| PDF 导出模板 | 后端字段 -> 模板引擎拼接 | 中(HTML 进入导出的文档) |
| 历史存量数据 | 老接口原样下发的 HTML | 高(不可控来源) |
| 静态页快照 | 导出 HTML 存入静态文件 | 高(对访问者无差别执行) |
我一开始以为风险只集中在详情页预览,因为那里是典型的innerHTML注入点。但排查后确认,影响面是“一旦脏 HTML 落库,后面所有渲染它的地方都会跟着遭殃”。特别是历史存量数据,团队从老版本系统迁移内容时,很多文档就是直接把旧 HTML 存进新库的,没有经过 Quill 的 Delta 转换,里面有什么根本不可控。
这解释了为什么问题单上强调“导出功能”——因为在很多系统里,导出 HTML 是上游数据进入下游渲染的必经之路,只要这段路上不加净化,任何一个使用该 HTML 的页面都可能变成攻击入口。
2. 漏洞原理拆解:导出链路里藏着的三个风险点
2.1 Delta 到 HTML 的“无意识映射”
Quill 的核心数据结构叫作 Delta,是一个由ops数组组成的对象。比如一段加粗的文字,在 Delta 里长这样:
const delta = { ops: [ { insert: '你好', attributes: { bold: true } } ] };Quill 内部通过 Parchment 机制,把这一个个insert和attributes映射成真实的 DOM 节点。加粗映射成<strong>,链接映射成<a href>,标题映射成<h1>,以此类推。getHTML()做的事情,本质上就是遍历当前 Delta,把这些 Blot 渲染成 HTML 字符串。
问题在这里就埋下了:这个映射过程只求“还原”,不求“过滤”。Quill 只负责把自己认识的格式还原成对应标签,但至于href的值是https://example.com还是javascript:alert(1),它不会替你做语义判断。这就像一个很勤快的翻译官,你递给他什么内容他都如实翻译,但内容是否危险由不得他管。
举个例子,如果在编辑器里通过 API 插入一个带链接的文字:
quill.insertText(0, '点我', { link: 'javascript:alert(1)' });再调用quill.getHTML(),得到的 HTML 里就会赫然出现:
<a href="javascript:alert(1)">点我</a>Quill 并没有对链接协议做强制白名单。这一点在不同版本里略有差异,部分版本会试图过滤data:前缀,但javascript:这类协议往往不在它的默认检查范围内。真正的问题是:下游渲染时,浏览器会忠实地执行这个“合法”的href。
2.2 链接协议和资源地址的隐形后门
富文本 XSS 最常见的载体,不只是<script>标签,而是那些看起来无害、实际能执行代码的协议和属性。我把导出 HTML 中值得重点盯防的对象分成了四类:
第一类是链接协议。javascript:和data:text/html是最典型的。攻击者不需要在编辑器里输入标签,只需要在“插入链接”的弹窗里填一个javascript:alert(document.cookie),如果前端没有在提交前校验链接协议,这条链接就会被原样保存、原样导出。
第二类是图片的错误事件。类似<img src="x" onerror="alert(1)">,一旦图片加载失败,onerror就会执行。这类事件属性能绕过大部分简单的标签黑名单。
第三类是带srcset属性的图片和带srcdoc的 iframe。srcset可以被用来隐藏真实 URL,iframe srcdoc则可以直接内嵌一段完整的 HTML 页面,在渲染端打开一个“页面中的页面”。
第四类是 CSS 表达式和style属性。虽然现代浏览器对 CSS 表达式的支持已经很有限,但background-image: url(javascript:...)等技巧在某些旧版本渲染环境下依然存在风险,而且style属性也是 XSS 过滤器最容易误判放行的地方。
在导出链路里,这四类对象只要有一类没有被下游过滤,就存在被利用的可能。现实中很多系统的过滤策略只盯<script>,却忽略了这几类,等于漏掉了大半风险面。
2.3 “粘贴入站”与“导出出站”共用一条不安全通路
还有一个容易被忽略的点:Quill 的“导出”不只是getHTML(),还包括粘贴时对 HTML 的解析路径。Quill 提供了一个方法叫dangerouslyPasteHTML,从名字就能看出官方态度——这个方法专门用来把一段 HTML 字符串解析进编辑器,但没有对安全性做过多承诺。
在实际使用中,常见的情况是这样的:用户从网页、邮件或 Word 里复制内容,然后粘贴到 Quill 编辑器。Quill 会先把这段 HTML 转成 DOM 树,再通过 Clipboard matcher 把 DOM 转成 Delta。这个过程是语法解析,不是安全过滤。很多事件属性、危险协议会在这个转换过程中被丢进内部数据结构,等getHTML()再导出时,又原样回到 HTML。
另一方面,存量数据和接口入参经常绕过编辑器。比如某个管理接口允许直接接收 HTML 片段,用于“富文本内容导入”。这些片段没有经过 Quill 的转换流程,就带着原始标签进入了存储层。到了渲染层,谁也不知道这段 HTML 里是干净的排版,还是带着onerror的陷阱。
所以我把这条链路称为“共用一条不安全通路”:入站没有清洗,出站没有消毒,中间的 Delta 转换又无法承担安全过滤的责任。任何一端缺口,都会让风险直接贯通到最终渲染页面。
3. 复现与排查实录:从概念验证到日志定位
3.1 搭一个最小复现环境
为了确认漏洞真实存在,并给出可复现的证据,我在本地搭了一个最小环境。页面结构非常简单:一个 Quill 编辑器、一个“导出并渲染”按钮、一个预览容器。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Quill XSS 复现环境</title> <link href="https://cdn.quilljs.com/2.0.0/quill.snow.css" rel="stylesheet"> </head> <body> <div id="editor"></div> <button id="export">导出并渲染</button> <div id="viewer"></div> <script src="https://cdn.quilljs.com/2.0.0/quill.js"></script> <script> const quill = new Quill('#editor', { theme: 'snow' }); document.getElementById('export').addEventListener('click', function () { const html = quill.getHTML(); // 模拟详情页 v-html 的渲染方式 document.getElementById('viewer').innerHTML = html; }); </script> </body> </html>这里必须说明,我在复现时用的是“验证型 Payload”,目的是确认渲染链路是否会对危险内容进行消毒,而不是真的去攻击任何一个线上系统。所有 Payload 都只在本地环境执行,并且只作用于当前测试页面本身。
3.2 构造验证用的安全 Payload
我准备的验证数据分两类:
第一类是可以通过编辑器“插入链接”功能直接录入的:
javascript:document.body.setAttribute('data-xss', 'link')第二类是通过接口或历史数据直接注入完整 HTML 片段:
<a href="javascript:document.body.setAttribute('data-xss','link')">点击链接</a> <img src="invalid" onerror="document.body.setAttribute('data-xss','img')"> <iframe srcdoc="<script>document.body.setAttribute('data-xss','iframe')</script>"></iframe>我用三条不同的数据进入路径做了对比测试:
| 进入路径 | 数据内容 | 导出后是否保留危险代码 | 渲染时是否触发 |
|---|---|---|---|
| Quill 编辑器人工输入 | <img src=x onerror=...> | 编辑器的图片格式会剥离部分额外属性,不一定保留 | 不一定 |
dangerouslyPasteHTML粘贴 | <iframe srcdoc=...> | 部分版本会转成 embed,部分版本原样保留 | 取决于版本 |
| 后端接口直接存入原 HTML | <a href=javascript:...> | 原样保留 | 确认触发 |
最稳定、最接近真实生产环境的一条路径是第三种:后端接口直接接收 HTML 片段并落库,然后详情页渲染时通过innerHTML注入。这正好对应了我在项目里看到的历史数据迁移场景。也就是说,攻击者完全可以构造一个请求,把一个仿冒的“富文本内容”直接提交到后端接口,而根本不需要经过编辑器的界面。
3.3 定位根因:接口层还是渲染层
复现出问题之后,我把这段流程细细走了一遍,试图定位到底哪一层应该背锅。首先看接口层,后端接收字段后没有对内容做任何合法性校验,直接存入数据库。再看渲染层,详情页拿到字段后,一次innerHTML赋值就把整段 HTML 注入到真实 DOM。中间没有任何一个环节调用过任何形式的净化函数。
从代码安全的角度来看,真正的根因不是某个组件写错了一行,而是整条链路把 HTML 当成了“可信的内部数据格式”。接口层信任前端,渲染层信任接口,存储层信任所有写入方。单看每一层好像都有道理,但连成一条链就处处是洞。
我排查时还发现一个细节:导出函数里并没有对javascript:协议做任何正则校验,而详情页渲染的地方也没接任何消毒库。也就是说,这条链路里连“最低配”的过滤都没有。定位结论因此很清楚:修复必须在链路中新增明确的净化节点,而不是指望某一层自动安全。
3.4 全链路排查清单
为了让排查过程可复用,我整理了一份简单清单,后面再遇到类似富文本 XSS 问题可以直接照着走:
- 列出所有生成 HTML 的入口:编辑器导出、粘贴导入、接口直传、历史数据导入。
- 列出所有消费 HTML 的出口:详情页渲染、列表摘要、PDF 模板、静态页生成、邮件模板。
- 检查每个入口是否存在“阻止脏数据进入”的逻辑,比如链接协议校验、标签白名单。
- 检查每个出口是否存在“输出前消毒”的逻辑,比如是否使用了安全净化库。
- 重点检查历史数据和接口直传字段,它们往往绕过了所有前端编辑器的处理。
这份清单虽然简陋,但在实际排查中帮了大忙,也让我确认了修复该从哪里下手。
4. 修复方案与安全加固实践
4.1 方案选型:只过滤输入等于高估单点可靠性
关于修复,团队里当时有两种意见:一种认为只要在粘贴入口做过滤就够了;另一种坚持在渲染出口做净化。我的建议是:两层都要做,但重心放在渲染出口。
只过滤输入的典型问题是,你永远不知道脏数据会从哪条路进来。用户可以用接口直接提交,也可以从历史数据里带出来,甚至可以依靠老版本系统同步过来。入口过滤意味着你要拦截每一条数据通路,这在现实中几乎不可能做完整。有一个历史数据漏掉,就可能在某次系统升级后被激活。
只过滤输出的缺点则是:如果某天有人绕过了出口净化,比如在一个新页面里忘了加消毒函数,风险又会回来。所以最终方案是把输出过滤作为“必须执行的安全红线”,同时在输入侧做尽可能多的拦截,减少脏数据落库。
这里也顺便说一个关键认知:用 Quill 这类编辑器,不代表内容就安全。它只是一个编辑工具,安全策略必须由使用方基于自己的业务场景来构建。官方文档里对getHTML()和dangerouslyPasteHTML的命名已经够直白了,不要把“编辑器帮我做完了”当成默认事实。
4.2 用 DOMPurify 给出口 HTML 做净化的标准姿势
输出端净化,我选择的是 DOMPurify。这是当前前端 HTML 净化最主流的方案,优点是默认配置已经能清掉绝大多数危险标签和属性,而且支持自定义白名单。
我给出的核心配置如下:
import DOMPurify from 'dompurify'; const SAFE_SCHEMA = { ALLOWED_TAGS: [ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ], ALLOWED_ATTR: [ 'href', 'target', 'rel', 'src', 'alt', 'title', 'width', 'height' ], ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i, FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'link', 'meta', 'script'], FORBID_ATTR: ['style', 'srcset', 'onerror', 'onload', 'onclick'] }; export function safeHtml(dirtyHtml) { return DOMPurify.sanitize(dirtyHtml, SAFE_SCHEMA); }这段配置有几个细节值得说清楚:
第一,我没有把style属性加入白名单。虽然 DOMPurify 默认会对 CSS 做一定清理,但为了彻底,我在配置里直接禁掉了style。业务排版如果确实需要某些样式,建议用自定义的类名,而不是允许内联 style。
第二,ALLOWED_URI_REGEXP限制了链接协议。它允许https:、mailto:、tel:,以及所有不是明显危险协议的协议。javascript:会被直接清成无效链接。这个正则也能阻止data:text/html这类高风险协议。
第三,FORBID_ATTR里我写了onerror一类事件属性。虽然 DOMPurify 默认就会处理它们,但显式写出来有两个好处:一是防止将来有人调整ALLOWED_ATTR时把这些事件属性加回去,二是给审计代码的人一个明确的意图说明。
实际接入渲染层时,只需要把innerHTML的赋值改成先消毒再渲染:
const html = safeHtml(article.content); document.getElementById('viewer').innerHTML = html;就这么简单的一行,能把绝大多数活跃的富文本 XSS 堵在门外。
4.3 在 Quill 粘贴入口提前拦截恶意内容
输出净化保证了下游安全,不过我还是建议在 Quill 粘贴入口也加一层过滤,因为这样可以减少脏数据落库,避免数据库里存一堆带风险的 HTML。
Quill 提供了 Clipboard matcher 机制,可以在粘贴时自定义某类标签的解析方式。针对链接,可以这样拦截危险协议:
const clipboard = quill.getModule('clipboard'); clipboard.addMatcher('a', function (node, delta) { const href = node.getAttribute('href') || ''; if (/^javascript:|data:text\/html/i.test(href)) { // 去掉危险链接属性,但保留文本 const copy = new Delta(delta); copy.ops.forEach(op => { if (op.attributes && op.attributes.link) { delete op.attributes.link; } }); return copy; } return delta; });对于图片,可以做一个白名单校验,只允许 http(s) 或指定格式的 base64 图片:
clipboard.addMatcher('img', function (node, delta) { const src = node.getAttribute('src') || ''; if (!/^https?:\/\//i.test(src) && !/^data:image\/(png|jpe?g|gif|webp);base64,/i.test(src)) { return new Delta(); // 直接丢弃 } return delta; });这里有一个实际问题:Quill 的 matcher 在解析时,会先把 HTML 转成 DOM,所以粘贴进去的<img onerror>属性在被 matcher 看到时,onerror已经在 DOM 上存在了。但 Delta 转换过程中,Quill 默认只保留它认识的属性,所以onerror在大多数情况下不会进入 Delta。真正危险的是那些直接被dangerouslyPasteHTML或后端接口带入的内容,所以我强调,入口过滤很重要,但不能替代出口过滤。
4.4 配合存量清洗和发布流程检查
修复并不只是改代码。一个已经跑了一段时间的系统,数据库里极可能已经存了一批未经净化的 HTML。这时候只改渲染层还不够,我建议做一次存量清洗,把所有富文本字段统一过一遍 DOMPurify,再把清洗后的结果写回库。
清洗脚本的大致流程是:
- 从数据库读取全部富文本字段,限制分批读取避免超时。
- 对每条内容调用
safeHtml()。 - 对比清洗前后的字符串,只更新有差异的记录。
- 记录包含危险内容的字段数量,用于复盘。
这里有个注意事项:直接批量清洗可能改变原有排版,因为 DOMPurify 会去掉不在白名单里的标签和属性。所以运行清洗脚本之前,最好先抽查一部分内容,看看预设白名单是否符合历史内容的排版需求。否则可能会出现“安全了但内容样式全乱了”的尴尬局面。
发布流程上也值得补一刀。后来团队规定:凡是想在 Vue 里用v-html或innerHTML绑定富文本字段,代码评审时必须看到对应的净化函数调用。这一条被写进了团队的前端安全 checklist,作为硬性要求。目的很简单,就是让人为的遗漏最少化。
5. 常见问题与避坑笔记
5.1 真实踩过的坑
在这个项目的排查和修复过程中,我踩过几个典型的坑,写出来帮你提前避一避。
第一个坑:只过滤<script>标签。一开始团队里有人提的方案是“把<script>标签去掉”,理由是攻击代码最常见。但实际上<svg onload>、<iframe>、<a href="javascript:">都能绕过这种单一黑名单。黑名单过滤本身就是低效的安全手段,因为攻击变体太多,你永远堵不完。
第二个坑:用正则处理 HTML。我看到过一些正则黑名单在服务端“净化” HTML,结果出现<scr<script>ipt>这类递归穿越问题。HTML 不是一个正则能正确解析的格式,处理它必须用真正的 DOM 解析器。DOMPurify 内部就是基于浏览器 DOM 解析来工作的,这也是我推荐它的原因。
第三个坑:只给“预览路径”加了净化,忘了“导出路径”。在很多系统里,编辑器的预览功能和导出功能走的是两套代码。有人给预览页面加了 DOMPurify,但真正的导出接口仍然直接返回原始 HTML,导致风险通过导出文件继续传播。排查时一定把所有下游消费点都列全。
第四个坑:忽略了srcset。图片的srcset属性可以传入复杂的候选地址,在某些安全库配置不当的情况下会成为绕过点。我在白名单配置里直接禁掉了srcset,如果你的业务确实需要响应式图片,请确保安全库能正确处理这个属性。
第五个坑:DOMPurify 不是万能药。它配置不当也会翻车,比如你把style加进ALLOWED_ATTR的同时,又没对 CSS 做细粒度限制,风险就可能在特定浏览器环境下残留。安全的姿势是:能用白名单解决的就不用黑名单,能禁掉的属性就不要犹豫。
5.2 排查顺序建议
如果你现在面对一个已经线上运行的系统,排查顺序建议这样安排:
- 先画数据流图,把从“编辑器”到“最终渲染”的所有路径列出来,包括导出文件和静态页。
- 然后在每个“出口”检查是否有净化函数,没有的就是高危点。
- 再看每个“入口”是否有过滤或校验,尤其是接口直传和历史数据导入。
- 优先修出口,因为出口是最后一层防线。
- 上线修复后,用开头的几条验证 Payload 重新走一遍全链路,确认所有出口都收口了。
这套顺序我跑过很多次,基本能覆盖绝大多数富文本 XSS 问题。
最后再分享一个习惯
这次排查给我最深的印象是:富文本 XSS 的根本原因,往往不是某个组件有“致命漏洞”,而是我们把 HTML 当成了内部传输格式,默认它可信。Quill 的getHTML()只是把 Delta 序列化成 HTML,它从来没有承诺输出的是安全 HTML。真正该负责的,是每一次把 HTML 交给innerHTML、模板引擎或另一个系统的那一行代码。
我后来在团队里立了一条规矩:凡是富文本字段要落到 HTML 渲染位,一律先过白名单消毒,没有例外。有人觉得这样多了一步麻烦,但经历过一次线上 XSS 告警之后,你就会知道,这一步换来的不只是安全,还有半夜不用起来看告警邮件的心安。这套流程跑通之后,再遇到类似编辑器组件,就不会慌——安全兜底方案是通用的,换一个编辑器也照样能用。