简介:面向前端开发者的 XSS 过滤开源库源码包,聚焦用户输入输出环节的安全防护,适合需要理解 XSS 防御机制、在项目中落地转义过滤策略的初中级开发者。压缩包共 30 个文件,约 66KB,以 20 个 JavaScript 源文件与测试脚本为主体,辅以 JSON 包配置、Markdown 说明文档、LICENSE、CI 配置等,体积轻量但结构完整。目录保留 src、dist、tests 及多版本压缩文件,便于对照源码、构建产物和单元测试理解过滤逻辑。已有 618 人学习,适合下载后本地调试,学习 HTML 标签、属性、URL 与 CSS 等上下文中的转义处理方式,并参考测试用例验证过滤规则,为项目集成和自定义安全策略提供依据。
1. 三个引号把「过滤」打穿了
先说结论:只把<和>替换掉的函数,不叫 XSS 过滤器,它只是恰好在「HTML 文本节点」这一个位置有效。我在一次内部代码评审里见过更典型的现场:一个搜索框的输入被塞进title="…"属性里,输入内容是" onmouseover="alert(1),尖括号一个没有,替换函数完全没触发,鼠标一划过就弹窗。那一刻大家的反应是「过滤得不够狠」,而真正的问题是——位置没分,字符替换得再狠也没用。
前端开源库 xss-filters 值得拿出来讲,恰恰因为它不是靠「删字符」解决问题,而是把每种输出位置当成一个独立问题来对待。这篇面向三类人:正在给老项目补防护、纠结要不要引入这个库的人;想搞清楚上下文逃逸到底怎么复现的人;以及踩过「我明明转义了」这种坑、想复盘因果的人。下面从原理、复现、落地、避坑一路走完,能抄的部分我会把代码和参数都写清楚。
2. 输出位置决定转义规则:五种上下文逐个拆
2.1 一次弹窗复盘:错在「位置」不在「字符」
把那次评审的代码简化出来,大概是这个样子:
// 反例:只处理尖括号的「过滤器」 function naiveEscape(input) { return String(input).replace(/</g, '<').replace(/>/g, '>'); } // 拼进属性 const html = `<a href="/search" title="${naiveEscape(keyword)}">搜索</a>`;输入" onmouseover="alert(1),经过naiveEscape之后原样输出,因为里面没有<也没有>。拼接结果变成:
<a href="/search" title="" onmouseover="alert(1)">搜索</a>属性被提前闭合,后面的内容被解析成了新属性。这就是上下文错配最朴素的样子:在双引号属性上下文里,危险字符是",而不是<。同样的输入如果落在文本节点里,一点事没有;落在<script>块里的字符串里,"又是另一种逃逸方式。
所以判断一个方案够不够格,第一件事不是看它转义了多少字符,而是问:它有没有区分我这段数据最终落在哪儿。市面上大量「安全工具函数」在这一关就过不了,因为它们只提供一个escape(),而这个函数的语义默认就是 HTML 文本节点。一旦被复用到属性、URL、脚本块里,它不是变弱了,是彻底失效了——而且失效得毫无声息,测试用例还全绿。
xss-filters 的设计思路就是把这件事显式化:不给你一个万能函数,而是给你一组方法,每个方法对应一种输出位置,用错了至少名字上看得出来。这算不上多先进,但对团队协作来说是个很实在的约束——评审时看到inDoubleQuotedAttr,就知道作者想过上下文这回事了。
顺带说清它的定位边界,这一点比 API 本身更重要:它是输出编码器,不是 HTML 净化器。它不做 HTML 解析、不维护标签白名单、不处理富文本。你给它一段<img src=x onerror=alert(1)>,它会老老实实把它编码成安全的一串实体字符,屏幕上显示的还是一坨尖括号文本。如果你的业务是「允许用户发带格式的富文本」,那它帮不了你,那是 DOM 解析型净化方案的活。搞混这两类工具的职责,是后面所有坑的源头。
2.2 xss-filters 的方法清单与参数含义
它的 API 面很小,常用的大致是这几组,我按「输出位置 → 方法」对应起来,做成表更好查:
| 输出位置 | 推荐方法 | 典型场景 | 主要处理对象 |
|---|---|---|---|
| HTML 文本节点 | inHTMLData | <div>…</div>中间 | & < > " ' /反引号、=等转实体 |
| HTML 注释 | inHTMLComment | <!-- … -->内 | 注释闭合相关字符 |
| 双引号属性 | inDoubleQuotedAttr | title="…" | 双引号、&、<、>等 |
| 单引号属性 | inSingleQuotedAttr | >// Node 侧引入(浏览器侧走打包工具或直接引入构建产物) const xssFilters = require('xss-filters'); const name = xssFilters.inHTMLData(user.name); // 文本节点 const title = xssFilters.inDoubleQuotedAttr(user.title); // 双引号属性 const sid = xssFilters.inSingleQuotedAttr(user.sid); // 单引号属性 const raw = xssFilters.inScriptString(user.raw); // 脚本字符串几个必须说清的点: 第一,这些方法的返回值是编码后的字符串,不是布尔值,也不是「已清洗」的对象。它不做任何「判断有没有攻击」的动作,只做编码。所以 第二, 第三,版本差异要自己核一遍。这类库更新不快,不同版本正则覆盖的字符集合会有出入,用之前把源码里那几个正则翻出来看一眼,比背文档靠谱。我在项目里习惯写一组断言用例把实际行为钉住,升级时跑一遍就知道有没有变化。 第四,它同时支持服务端与浏览器端使用,也就是说同一套编码逻辑可以在模板渲染层和前端拼接层复用。这个特性对老项目特别有用——往往服务端用的是某套模板引擎,前端又用字符串拼 DOM,两边的转义规则不统一,最后总是最弱的那个环节被突破。统一到一套编码器上,至少能减少「两边各转一次、转出问题」的情况。 2.3 把同一段脏数据喂给五个上下文光看表不够直观,我一般会写一段对照代码,把同一个输入扔进不同上下文,看输出差异: 跑出来会看到明显不同的编码结果:文本节点里尖括号变成实体,属性上下文里引号被编码,脚本上下文里那些引号会被转成 这一步还有额外收益:你可以把这些输出直接固化成单元测试。以后有人手滑把 3. 绕过复现:三种逃逸为什么手写替换挡不住3.1 属性逃逸、JS 字符串注入、URL 协议注入我把最常见的三类逃逸整理成可复现的最小样例,都是在本地静态页面上就能跑通的。注意这些 payload 的作用只是验证「编码缺失时会发生什么」,验证完立刻换回编码逻辑,别留在测试分支里。 第一类:双引号属性逃逸。 根因:属性值以双引号包裹,输入里的双引号没有被编码,直接被解析成了属性边界。如果属性值本身不加引号,逃逸门槛更低,一个空格就能跳出去。 第二类:脚本块字符串注入。 根因:JS 字符串上下文的危险字符是双引号、反斜杠、换行,以及 第三类:URL 协议注入。 根因和前两类不一样,值得单独强调:这不是「字符没转义」的问题,而是「协议没校验」的问题。 三类放在一起看,规律很清楚:逃逸方式由上下文决定,不由字符表决定。你不可能靠一个更长的黑名单把所有位置都覆盖住,因为每个位置的语法规则不一样。这就是为什么上下文编码路线在工程上更稳——它不猜攻击者会构造什么,它只保证输出符合该位置的语法。 3.2 换成上下文编码后的输出与验证把上面三个模板换成对应方法,改动很小: 三点说明,都是踩过之后才明白的:
URL 那一层必须叠协议白名单。 验证方式也别只靠肉眼看代码。我一般会开一个本地静态页,把三种上下文各放一个输入框,挂上浏览器控制台,输入 payload 后观察两件事:DOM 里属性值是否仍然是完整的一整段,以及控制台有没有执行痕迹。前者看结构,后者看执行。这两条都过,才算这一步改对了。 3.3 编码顺序与「先拼后转」的致命错误这一条我认为比所有 payload 都重要,因为它决定了前面所有编码工作有没有意义。 错误写法一:先拼后转。也就是先把模板和数据拼成完整 HTML,再对整个字符串做一次 正确的顺序永远是:先确定数据要落在哪个位置 → 对该位置做编码 → 再拼进模板。编码发生在拼接之前,一次一处,不重复编码。 错误写法二:重复编码。数据在服务端被 错误写法三:解码后重新使用。有人为了做「二次加工」,把编码后的值解码回来拼到别的位置,然后忘了重新编码。这条链路一旦形成,前面所有防护等于零。看到代码里有 还有一个容易被忽略的顺序问题:不同上下文之间不能「借用」编码结果。比如一个值先被 4. 工程落地:字符串模板、构建链与 CSP 的三层配合4.1 在拼接层做「最后一米」编码这个库实际能发挥价值的地方,是老项目的字符串拼接层。现代组件框架大多默认转义,只有你显式使用「原始 HTML 渲染」才需要担心——但那类场景往往已经改用净化库了。所以对它最客观的定位是:给手写 HTML 字符串的地方补上一层结构化的编码。 一个典型的服务端模板封装,长这样: 用的时候: |