☰
KindEditor防XSS实战:5个关键配置与服务端净化方案
2026/10/22 11:13:41 网站建设 项目流程

前阵子接手了一个老项目,后台管理系统用的正是KindEditor,跑了五六年一直相安无事。结果安全扫描报告下来,发现富文本提交接口存在存储型XSS漏洞,攻击者只要在编辑器里粘贴一段恶意代码,后台管理员一打开文章就直接被种了马。排查后我发现,问题不在编辑器本身,而是接入方式太随意——该开的开关没开,该配的白名单没配,上传接口和提交接口的校验几乎等于裸奔。后来我把整套配置重新梳理了一遍,从那以后同类漏洞再没出现过。这篇文章就把我整理的方案、踩过的坑,以及防止XSS攻击的5个关键配置一次性讲清楚。

1. 攻击面拆解:富文本编辑器为什么是XSS重灾区

1.1 编辑器给了攻击者一张"半合法"的通行证

先聊一个最基础的问题:为什么所有富文本编辑器都容易出XSS,而普通输入框反而不那么让人头疼?

普通输入框(比如用户名、留言),后端拿到数据后只要转义掉<>'"这些特殊字符,数据到前端就只是一段纯文本,浏览器不会把它当作标签解析,攻击者想注入脚本只能碰运气。但富文本编辑器的核心功能就是让用户提交"受控的HTML"——加粗、换行、插图片、挂链接,这些操作天然要求前端把用户输入当作HTML渲染。这就等于给攻击者发了一张"半合法"的通行证:他不需要突破转义,只需要找到你允许的标签和属性里,哪些能被用来执行脚本。

常见的路子就那么几条,但每一条都很致命:

  • img标签的onerror属性:<img src="x" onerror="alert(1)">,图片加载失败瞬间触发脚本。
  • a标签的javascript:协议:<a href="javascript: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/onload=alert(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 href="javascript: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构造请求,绕过前端,把<script>alert(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, tags=allowed_tags, attributes=allowed_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 charset="utf-8"> <link rel="stylesheet" href="/static/kindeditor/themes/default/default.css"> <script src="/static/kindeditor/kindeditor-all-min.js"></script> </head> <body> <textarea id="editor_id" name="content" style="width: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。我接手过一个项目,新版配置上线后,我用净化脚本把全库历史文章重新跑了一遍,结果真捞出来两条伪装成图片标签的恶意脚本——那都是好几年前某次攻击留下的尾巴。这一步虽然麻烦,但对于以内容为核心的系统,绝对值得做一次。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询