KindEditor防XSS实战:5个关键配置与服务端净化方案
前阵子接手了一个老项目后台管理系统用的正是KindEditor跑了五六年一直相安无事。结果安全扫描报告下来发现富文本提交接口存在存储型XSS漏洞攻击者只要在编辑器里粘贴一段恶意代码后台管理员一打开文章就直接被种了马。排查后我发现问题不在编辑器本身而是接入方式太随意——该开的开关没开该配的白名单没配上传接口和提交接口的校验几乎等于裸奔。后来我把整套配置重新梳理了一遍从那以后同类漏洞再没出现过。这篇文章就把我整理的方案、踩过的坑以及防止XSS攻击的5个关键配置一次性讲清楚。1. 攻击面拆解富文本编辑器为什么是XSS重灾区1.1 编辑器给了攻击者一张半合法的通行证先聊一个最基础的问题为什么所有富文本编辑器都容易出XSS而普通输入框反而不那么让人头疼普通输入框比如用户名、留言后端拿到数据后只要转义掉这些特殊字符数据到前端就只是一段纯文本浏览器不会把它当作标签解析攻击者想注入脚本只能碰运气。但富文本编辑器的核心功能就是让用户提交受控的HTML——加粗、换行、插图片、挂链接这些操作天然要求前端把用户输入当作HTML渲染。这就等于给攻击者发了一张半合法的通行证他不需要突破转义只需要找到你允许的标签和属性里哪些能被用来执行脚本。常见的路子就那么几条但每一条都很致命img标签的onerror属性img srcx onerroralert(1)图片加载失败瞬间触发脚本。a标签的javascript:协议a hrefjavascript:alert(1)点我/a用户一点就中招。iframe内嵌钓鱼页面或恶意脚本iframe src//恶意站点/iframe直接在你的页面里开了一个窗口。svg、math、video、audio这些冷门标签配合onload、onerror同样可以执行脚本。style属性里藏东西老版本浏览器下用expression()能执行脚本现代浏览器虽然堵住了这条路但style属性仍然可能造成点击劫持、钓鱼覆盖等问题。更麻烦的是富文本编辑器的提交内容到达后端时它的数据形态是看起来像正常排版HTML的字符串。如果后端不对这段字符串做任何处理直接入库再原样输出到管理页面那这条链路就完全打通了任何能操作编辑器的人等于拿到了一段能直接在目标页面上执行任意代码的钥匙。这还不是最可怕的——真正可怕的是存储型XSS恶意内容会被永久保存每个打开页面的管理员都是受害者。1.2 KindEditor默认的过滤逻辑以及它管不住的地方KindEditor作为一款经典的开源富文本编辑器本身是有安全意识的设计。它默认开启了filterMode过滤模式内部有一套内置的标签白名单会尝试把不安全的标签剥掉。比如script、iframe、object、embed这些高危标签默认情况下都会被直接过滤带事件前缀的属性onclick、onerror、onload等也进不了白名单。也就是说如果你什么都不配编辑器自己会做一次基础过滤。但这里有几个关键局限是很多项目栽跟头的地方第一内置白名单偏宽松。默认规则允许的标签集合覆盖了大部分排版标签属性里也包含style这类相对麻烦的东西。style属性本身不是XSS的直接入口但配合低版本浏览器或者某些特定CSS特性能玩出很多花活。安全实践上白名单越窄越好。第二filterMode只管编辑器内的HTML。如果用户切到源码模式直接手写HTML粘贴进去某些版本下过滤逻辑并不会严格扫描源码模式的输入或者过滤规则可以被构造的畸形标签绕过。前端过滤本质上是文本替换正则清理对于复杂嵌套、大小写混写、属性值编码变异等情况总有覆盖不到的犄角旮旯。第三前端配置对恶意请求完全无效。这一点最重要——攻击者根本不一定要打开你的编辑器页面。他可以拿抓包工具直接构造一个带恶意HTML的POST请求跳过所有前端校验直达你的提交接口。所以前端配置只是减少正常用户的误触风险、提升攻击成本真正的安全防线必须落在服务端。2. 五个关键配置一个一个说清楚2.1 配置一把filterMode打开并理解它的边界KindEditor创建编辑器时的标准写法是在KindEditor.ready里调用K.create配置项以对象形式传入。filterMode的默认值本身就是true但我在不少老项目里见过有人为了让粘贴的代码不被过滤主动把它设成false这等于把编辑器的默认防线直接拆了。KindEditor.ready(function (K) { K.create(#editor_id, { filterMode: true }); });这个配置的作用是当用户从外部复制内容粘贴进来或者在源码模式输入HTML时KindEditor会调用内部的过滤逻辑把不合法标签、不合法属性、明显的事件处理代码筛掉。建议永远保持true并且在代码注释里写明禁止擅自关闭。但正如前面提到的filterMode的边界在于它是字符串级别的清理不是语义级别的净化。它可以帮你干掉script和onload但它可能放行某些你看不出问题、却能利用浏览器解析差异产生效果的畸形标签。比如svg/onloadalert(1)这类变体在部分浏览器的解析规则下会被当作合法标签处理而正则过滤不一定识破。所以filterMode是重要但不充分的第一层不能只有它。2.2 配置二用htmlTags自定义白名单砍掉用不上的标签htmlTags是KindEditor里最值得花时间调的一个配置。它允许你完全覆盖内置的标签白名单只保留业务真正需要的标签和属性。我的经验是默认白名单虽然安全系数尚可但它面对的是通用场景给所有标签开了口子。你是一个内容管理后台根本不需要object、embed、iframe这些标签但默认规则可能还留着它们的位置这就是风险面。下面这套白名单是我在多个实际项目中打磨过的基本覆盖了文章排版需要的标签同时对高危标签零容忍KindEditor.ready(function (K) { K.create(#editor_id, { filterMode: true, htmlTags: { p: [class, style], div: [class, style, align], span: [class, style], a: [href, title, target, rel], img: [src, alt, title, width, height], strong: [], em: [], u: [], blockquote: [class], pre: [class], ul: [class], ol: [class], li: [class], table: [border, cellspacing, cellpadding, width, class], thead: [], tbody: [], tr: [align, valign, class], td: [rowspan, colspan, align, valign, class, width, height], th: [rowspan, colspan, align, valign, class, width, height], h1: [class], h2: [class], h3: [class], h4: [class], h5: [class], h6: [class] } }); });注意几个关键点script、iframe、object、embed、link、meta、style这些标签直接不列进去编辑器产生的HTML里就不会出现它们。a标签保留href但href的内容光靠白名单是管不住的还需要配合服务端协议校验下面配置三会细说。img保留src但src同样要防伪协议而且最好把onerror这种属性从白名单里彻底抹掉。列表里strong: []表示这个标签允许出现但任何属性都不允许。干净的标签就是一个纯粹的语义标签不携带任何可执行内容。style属性我没有完全去掉因为业务方确实需要文字颜色、行距、缩进这些排版需求。但我要强调如果你对style属性没有强需求建议直接移除这是XSS和CSS注入的潜在通道。2.3 配置三地址协议限制和urlType策略白名单解决了标签和属性的合法性但没解决属性值的合法性。最典型的就是a[href]和img[src]这两个属性它们接受URL字符串而URL的协议可以被攻击者操纵。危险协议里javascript:是最经典的XSS入口a hrefjavascript:alert(document.cookie)点击领取奖励/a如果这个链接被渲染到管理后台管理员鼠标一点脚本就在后台的权限上下文里执行了。类似的还有vbscript:老版本IE和data:在部分场景下可以被当作HTML解析。所以凡是允许用户输入URL的地方后端必须校验协议白名单只允许http、https、mailto看业务需求其他协议一律拒绝。KindEditor这边有一个辅助配置叫urlType它负责统一编辑器内图片和链接的URL格式。典型值是空字符串保持原样、relative相对路径、absolute绝对路径、domain带域名绝对路径。从安全角度我推荐设为relative因为它能避免把用户输入的绝对地址原样塞回HTML里同时统一了存储格式KindEditor.ready(function (K) { K.create(#editor_id, { filterMode: true, urlType: relative }); });要说明的是urlType本身不负责拦截危险的javascript:协议它的作用更多是地址规范化。真正的协议校验还是得靠服务端做。前端这层能做的是确保htmlTags的白名单里不给href之外的属性开协议口子然后后端用下面的方案收口。2.4 配置四关闭allowFileManager收紧上传接口KindEditor有一个很实用的功能叫文件管理器开启后用户可以浏览服务器上已经上传的文件甚至能浏览目录结构。这个功能本身是为了方便用户在历史文件里选择引用但说实话它带来的安全风险远大于便利如果目录遍历接口有鉴权漏洞等于把你服务器的文件结构暴露给攻击者。攻击者可以找到历史上传的HTML文件往里面塞恶意代码再诱导管理员访问。文件管理器的操作接口如果没做权限校验可能被用来覆盖或删除服务器文件。所以在非必要场景下我强烈建议直接关闭KindEditor.ready(function (K) { K.create(#editor_id, { filterMode: true, urlType: relative, allowFileManager: false, allowImageUpload: true, allowMediaUpload: false, allowFileUpload: false, uploadJson: /api/upload/kindeditor }); });如果业务确实需要图片上传那上传接口本身必须做好四件事第一鉴权。上传接口必须走和后台相同的登录态校验不能裸奔在公网。第二类型校验。不能只看文件后缀必须检测文件内容头。常见做法是用服务端图片库读取图片信息宽高、格式、色彩空间读不出来的直接拒绝。一个伪装成.jpg的HTML文件用图片库解码时必然报错。第三目录隔离。上传文件统一放在一个独立域名或独立目录下这个域名不携带任何Cookie即使上传了恶意HTML脚本也拿不到主站的登录态。第四返回格式严格。KindEditor的上传接口要求返回特定JSON结构形如{ error: 0, url: https://cdn.example.com/uploads/2024/01/abc.jpg, message: 上传成功 }后端在返回url前确保这个地址是可访问的图片地址而不是用户可控的任意字符串。曾经有项目在这个环节被注入了伪造地址结果富文本里嵌入了指向恶意站点的图片链接。2.5 配置五服务端HTML净化前端配置不是终点这是五个配置里最重要的一个也是我接触的项目里最容易被忽略的一个。很多团队的思路是前端配好了白名单就万事大吉后端只负责把字符串存进去。这个思路在安全视角下完全是反的。前端配置是给正常用户用的交互层攻击者根本不需要通过你的编辑器。他会直接用curl、Postman、Burp Suite构造请求绕过前端把scriptalert(1)/script直接提交到接口。如果你的后端没有HTML净化那这一段脚本就会原样入库、原样输出、原样执行。服务端净化的标准做法是使用成熟的HTML白名单过滤库。用Java的项目可以选jsoup的Safelist旧名是Whitelist机制import org.jsoup.Jsoup; import org.jsoup.safety.Safelist; String inputHtml request.getParameter(content); Safelist safelist Safelist.relaxed() .addTags(p, div, span, a, img, ul, ol, li, strong, em, u, blockquote, pre, table, thead, tbody, tr, td, th, h1, h2, h3, h4) .addAttributes(a, href, title, target) .addAttributes(img, src, alt, title, width, height) .addAttributes(td, rowspan, colspan, align, valign, width) .addProtocols(a, href, http, https) .addProtocols(img, src, http, https); // 注意这里强制协议白名单javascript: 直接被丢弃 String cleanHtml Jsoup.clean(inputHtml, http://example.com, safelist, new Document.OutputSettings().prettyPrint(false));用PHP的话业界标准是HTMLPurifier它不仅能过滤标签属性还会做CSS属性过滤比手写正则可靠太多require_once htmlpurifier/HTMLPurifier.auto.php; $config HTMLPurifier_Config::createDefault(); $config-set(HTML.Allowed, p[style],div[class],span[class],a[href|title|target],img[src|alt|width|height],strong,em,ul,ol,li,table[border],tr,td[rowspan|colspan|align],th,blockquote,h1,h2,h3,h4); $config-set(URI.AllowedSchemes, [http true, https true, mailto true]); $purifier new HTMLPurifier($config); $cleanHtml $purifier-purify($inputHtml);用Python的话bleach库是常见选择import bleach allowed_tags [p, div, span, a, img, ul, ol, li, strong, em, u, blockquote, pre, table, thead, tbody, tr, td, th, h1, h2, h3, h4] allowed_attrs { a: [href, title, target], img: [src, alt, title, width, height], td: [rowspan, colspan, align, valign, width], th: [rowspan, colspan, align, valign, width], } clean_html bleach.clean(input_html, tagsallowed_tags, attributesallowed_attrs, protocols[http, https, mailto])这套服务端净化放在哪一层我的经验是放在提交接口的入库之前。接口收到内容先过净化库把净化后的HTML存进数据库。这样攻击者的原始payload永远不会触达存储层后续不管前端怎么改都不会影响安全性。3. 部署中最容易踩的三个坑3.1 白名单写得太宽或太窄白名单太宽的例子把object、embed、iframe都加进去理由是客户要嵌入视频。嵌入视频确实有业务需求但正确做法是只允许iframe的src指向白名单域名比如特定视频平台而不是把所有iframe都放行。或者更稳妥的做法不用富文本直接嵌入改成让用户填视频ID前端统一渲染。白名单太窄的例子只留了p、br、strong结果运营人员粘贴进来的表格、列表、高亮全被过滤了页面排版稀烂。这种情况业务方会觉得编辑器坏了反过来又逼着技术人员往白名单里加标签。我的建议是白名单要以真实业务内容为准提前和运营、编辑确认常用排版需求宁可配置时多花半小时不要上线后被业务追着改。3.2 源码粘贴和源码模式会绕过过滤器KindEditor有个HTML源码功能用户可以切换到源码视图直接编辑HTML。这个功能在安全上是个坑用户在源码模式里粘贴的内容有些版本并不会走和可视模式相同的过滤逻辑或者过滤得不够彻底。我遇到的真实案例运营人员从另一个网站上复制了一段带onerror的img标签在源码模式里粘贴进去。后端净化只做了标签级过滤事件属性却被漏掉了结果一上线就出现了XSS弹窗。排查后才发现那个HTML净化配置里加了img的src、width、height等属性但忘了一刀切地把所有on*属性排除掉。避免这个坑的方法就是前面配置五说的不要依赖KindEditor自身的过滤后端净化时明确只允许白名单属性其他属性一律丢弃。如果用jsoup可以给所有标签只允许显式列出的属性事件属性天然不会在白名单里直接被清掉。3.3 输出端没有统一编码还有一类XSS不太容易意识得到它甚至和编辑器无关你把HTML存进数据库的时候是干净的但是页面输出时如果某个字段比如文章标题、作者名是用户可控的纯文本而模板里直接用了{{ title }}而不是{{ title|escape }}那这段纯文本里包含的script依然会执行。这在富文本项目里特别容易混文章正文要输出HTML所以你会用不转义的模板语法但同页面其他字段如果也用了不转义语法就会形成新的XSS入口。我的习惯是写模板时默认全部转义只有content字段走白名单过滤后的HTML渲染其他任何字段都不允许裸奔输出。安全就是这样在每一个出口都要问一句这段数据是纯文本还是可信HTML如果是纯文本就必须转义。4. 配置生效后的排查清单4.1 常见故障速查表配置完之后实际运行中会遇到各种表现怪异的问题。我把这些年遇到的典型故障整理成一张速查表方便你对照排查现象可能原因处理方式粘贴过来的表格样式全丢了htmlTags白名单没包含table、tr、td或属性按业务需求补充表格标签及对齐属性注意td的rowspan、colspan图片上传成功后编辑器里不显示uploadJson路径不对或返回的JSON没有url字段用浏览器开发者工具看上传请求的响应体确认返回结构与官方要求一致编辑器里能上传但图片打开404上传接口存了文件但返回路径有误或urlType把绝对路径转成了相对路径导致拼接错误检查上传目录的访问权限和URL拼接逻辑建议统一用urlType: relative配合站点根路径解析文档里嵌套的iframe被过滤htmlTags里没写iframe说清楚一个事实你就不该允许iframe如果想嵌入视频换成只允许特定白名单域名的方案保存后HTML里出现大量style属性或class残留业务方用了太多无意义的行内样式要么在净化器里统一清理样式属性要么让业务方改用固定样式类提交接口返回500服务端净化库初始化报错检查净化库的依赖是否引入本地先跑一个最小单测管理员打开文章页面弹出alert攻击者已经成功提交了存储型XSS立即下线页面用净化库重跑已有所有历史数据后重新入库排查提交接口日志联网搜索文件时看到敏感目录allowFileManager开着立刻设为false并检查服务器该目录权限4.2 一套可以直接抄的整合配置把前面讲到的所有配置整合到一起形成一个可以用在新项目上的基础模板!DOCTYPE html html head meta charsetutf-8 link relstylesheet href/static/kindeditor/themes/default/default.css script src/static/kindeditor/kindeditor-all-min.js/script /head body textarea ideditor_id namecontent stylewidth:100%;height:300px;/textarea script KindEditor.ready(function (K) { K.create(#editor_id, { filterMode: true, urlType: relative, allowFileManager: false, allowImageUpload: true, allowMediaUpload: false, allowFileUpload: false, uploadJson: /api/upload/kindeditor, htmlTags: { p: [class, style], div: [class, style, align], span: [class, style], a: [href, title, target], img: [src, alt, title, width, height], strong: [], em: [], u: [], blockquote: [class], pre: [class], ul: [class], ol: [class], li: [class], table: [border, cellspacing, cellpadding, width, class], thead: [], tbody: [], tr: [align, valign, class], td: [rowspan, colspan, align, valign, class, width, height], th: [rowspan, colspan, align, valign, class, width, height], h1: [class], h2: [class], h3: [class], h4: [class], h5: [class], h6: [class] }, afterChange: function () { // 在每次内容变化时记录一下方便排查提交前被改动的内容 this.sync(); } }); }); /script /body /html这里的afterChange是一个我特别建议保留的回调它会在编辑器内容变化时触发调用this.sync()保证文本域里的值始终和编辑器同步。否则表单提交时可能拿到的是旧值出现明明改了但保存的还是老内容的怪问题。5. 几句掏心窝的话聊几句我自己的体会。安全配置这件事最怕的就是配完就忘。KindEditor本身并不复杂但你把它接入系统的那一刻它就成了整个后台安全链路的一环。前端五个配置只是第一道闸门服务端净化才是真正兜底的防线。如果团队里有人图省事关掉filterMode或者后端跳过净化直接存库之前做的所有配置都会功亏一篑。我自己现在的习惯是每次改完配置都用一套固定的XSS测试payload去自测。什么img onerror、a 伪协议、iframe srcdoc、svg onload全过一遍确保提交接口返回的内容里这些payload都被过滤干净了才敢上线。另外每半年会回头检查一次KindEditor的版本看官方有没有新的安全补丁。这套流程花不了多少时间却能帮你省掉很多提心吊胆的夜晚。还有一个容易忽略但很实用的点给历史数据补课。很多老项目配置了新的安全策略之后库里可能还躺着大量历史文章里面也许混着恶意HTML。我接手过一个项目新版配置上线后我用净化脚本把全库历史文章重新跑了一遍结果真捞出来两条伪装成图片标签的恶意脚本——那都是好几年前某次攻击留下的尾巴。这一步虽然麻烦但对于以内容为核心的系统绝对值得做一次。