简介:前端开源库-xss-filters是一份面向前端开发者与安全入门者的XSS防护工具源码包,用于解决用户输入在页面输出时可能被注入恶意脚本的问题。资源共30个文件,以20个js源码与测试脚本为主体,辅以3个json配置、3个md说明文档,另有yml、license等辅助文件,压缩包约66KB,体积轻量便于集成。内容涵盖核心过滤逻辑、单元测试、模糊测试用例及多版本压缩产物,可帮助读者理解HTML标签、属性、URL与CSS等场景的转义与清理策略,并支持按项目需求自定义过滤规则。目前已有617人学习下载,适合希望掌握防御性编程、降低XSS漏洞风险的前端工程师参考,也可作为安全编码实践的入门素材。
1. 前端开源库 xss-filters:把用户输入塞进 DOM 之前,先过一遍这道滤网
做前端的人迟早会撞上 XSS。你从接口拿到一段用户昵称,顺手innerHTML塞进页面,本地跑得好好的,上线第二天安全同学甩过来一条告警:某条昵称里带了<img src=x onerror=...>,页面一加载就执行了。这类问题不是靠“我小心一点”能解决的,得靠一层稳定的过滤逻辑。xss-filters 就是干这个的前端开源库——它把不可信数据按输出上下文做转义,让你在拼 HTML、写属性、塞 URL 的时候有个统一的出口。它适合谁?适合那些还在用字符串拼接渲染、又不想引入整套框架级防护的团队,也适合想搞明白“转义到底在转什么”的初中级前端。这篇不聊虚的,从它解决什么问题讲到怎么接进项目、参数怎么传、哪些坑我踩过。
2. xss-filters 的上下文转义逻辑:为什么不能一把梭哈
2.1 转义的本质是“看菜下饭”
很多人对 XSS 防护的理解停留在“把<换成<”。这个做法在纯 HTML 文本节点里没问题,但换个场景就翻车。比如你要把用户输入拼进href,<转义了没用,攻击者用javascript:alert(1)照样能点。再比如拼进onclick这种事件属性,转义规则又不一样。xss-filters 的核心思路就是:不同输出位置,用不同的转义函数。
它提供的函数大致分几类:
| 输出位置 | 对应函数 | 处理对象 |
|---|---|---|
| HTML 文本内容 | inHTMLData | 标签之间的文本 |
| HTML 属性值 | inHTMLAttr | 双引号包裹的属性 |
| 单引号属性值 | inSingleQuotedAttr | 单引号包裹的属性 |
| 无引号属性值 | inUnQuotedAttr | 裸属性值 |
| URL 参数 | uriInHTMLData/uriInHTMLAttr | 拼进链接的查询串 |
| JS 字符串 | inScriptData | script 标签内的字符串 |
这个设计的好处是逼你思考“我现在到底在哪个上下文”。我见过太多项目全局挂一个escapeHtml,结果在 URL 场景下形同虚设。xss-filters 不给你这种偷懒的机会,用哪个函数取决于你把数据放哪。
2.2 它和框架自带防护的区别
Vue、React 默认会对插值做转义,但它们的防护边界在“模板语法内”。一旦你用v-html、dangerouslySetInnerHTML,或者手动document.write,框架就不管了。xss-filters 的定位是补这个缺口——它不依赖框架,纯函数,你可以在任何地方调用。
另一个区别是它处理的是字符串级转义,不是 DOM 级净化。像 DOMPurify 那种库是把一整段 HTML 解析后过滤标签,适合“允许部分富文本”的场景。xss-filters 更适合“我只要纯文本,任何标签都不许有”的场景。选型时先想清楚:你要的是白名单富文本,还是彻底禁止标签?前者用 DOMPurify,后者用 xss-filters 更轻。
2.3 引入方式与基本调用
它是老牌库,支持 CommonJS 和 AMD,也能直接 script 引入。现代项目里我一般这样接:
# 通过包管理器安装(注意包名) npm install xss-filters// 按需引入具体函数,避免整包打进去 const { inHTMLData, inHTMLAttr, uriInHTMLData } = require('xss-filters'); // 场景一:把用户昵称渲染到标签之间 const nickname = '<img src=x onerror=alert(1)>'; const safeNickname = inHTMLData(nickname); // 输出:<img src=x onerror=alert(1)> document.getElementById('name').innerHTML = safeNickname; // 场景二:拼进 href 的查询参数 const keyword = '"><script>alert(1)</script>'; const safeKeyword = uriInHTMLData(keyword); const link = `/search?q=${safeKeyword}`; // 此时 keyword 里的特殊字符已被 URI 编码,无法闭合属性或注入标签逻辑说明:inHTMLData会把<、>、&、"、'等字符转成实体,确保内容只能作为文本显示。uriInHTMLData先做 URI 编码,再处理 HTML 实体,保证拼进 URL 时不会破坏结构。参数方面,这些函数都只接受一个字符串参数,传null或undefined会返回空字符串,不会抛异常——这点在接口字段可能为空时很省心。
提示:不要对已经转义过的字符串再转一次,
<会被转成&lt;,页面上就会显示成<而不是<。转义只做一次,在数据进入 DOM 之前做。
3. 把 xss-filters 接进真实渲染流程:从接口到 DOM 的完整链路
3.1 数据流梳理:在哪一层做过滤
我一般把过滤放在“数据即将进入 DOM 的那一层”,而不是接口返回就立刻转。原因很简单:同一份数据可能被用在多个上下文,接口层转义了,后面想拼进 URL 就得多一次解码,反而乱。正确做法是数据保持原始状态,在渲染函数里按上下文调用对应过滤器。
以一个搜索建议列表为例,链路是这样的:
// 假设从接口拿到原始数据 const rawData = { keyword: '<b>测试</b>', url: '/detail?id=1&name=<script>', count: 10 }; // 渲染函数里按位置分别处理 function renderSuggestion(item) { // 文本节点用 inHTMLData const safeKeyword = inHTMLData(item.keyword); // 属性值用 inHTMLAttr(假设属性用双引号包裹) const safeUrl = inHTMLAttr(item.url); return `<li>const { inHTMLData, inHTMLAttr, uriInHTMLData } = require('xss-filters'); // 定义每个字段的过滤策略 const filterMap = { title: inHTMLData, author: inHTMLData, avatar: inHTMLAttr, profileUrl: inHTMLAttr, searchQuery: uriInHTMLData }; function safeRender(template, data) { const filtered = {}; Object.keys(data).forEach(key => { const filter = filterMap[key]; // 没有配置策略的字段默认按 HTML 文本处理 filtered[key] = filter ? filter(String(data[key])) : inHTMLData(String(data[key])); }); // 这里用简单的占位符替换,实际项目可换成模板引擎 return template.replace(/\{\{(\w+)\}\}/g, (_, k) => filtered[k] || ''); } const tpl = '<div class="card"><img src="{{avatar}}"><a href="{{profileUrl}}">{{title}}</a><span>{{author}}</span></div>'; const html = safeRender(tpl, { title: '<script>alert(1)</script>', author: 'A同学', avatar: 'x" onerror="alert(1)', profileUrl: '/user?id=1' });逻辑说明:filterMap把字段和过滤函数绑定,新增字段时只要加一行配置。safeRender遍历数据,对每个字段调用对应过滤器,没有配置的走默认inHTMLData。这样即使漏配,也不会完全裸奔。参数上,String(data[key])是为了防止数字、布尔值传进过滤器时报错——虽然库本身能处理,但显式转换更稳。
注意:这个
safeRender只是演示思路,生产环境如果模板复杂,建议用成熟模板引擎的转义机制,xss-filters 作为补充。不要自己写正则解析 HTML,那是另一个坑。
3.3 和构建工具配合:按需打包
xss-filters 的包不大,但如果只用其中两三个函数,全量引入还是浪费。用 ES module 的按需导入,配合 webpack 或 rollup 的 tree-shaking:
// 只导入用到的函数 import { inHTMLData, inHTMLAttr } from 'xss-filters'; // 使用 const safe = inHTMLData(userInput);如果项目用的是老版本构建工具不支持 tree-shaking,可以直接引具体文件路径(视包结构而定),或者用require('xss-filters/lib/inHTMLData')这种形式。具体路径以实际安装后的目录为准,装完看一眼node_modules/xss-filters的结构就清楚了。
参数配置上,这个库没有全局配置项,所有行为由你调用的函数决定。这意味着没有“配置错了导致全局失效”的风险,但也意味着每个调用点都得自己负责。我一般会在代码规范里加一条:任何innerHTML赋值,右侧必须出现 xss-filters 的函数调用,code review 时重点看这个。
4. 避坑与排查:那些让我加班到凌晨的 XSS 过滤问题
4.1 现象:转义后页面显示<而不是<
原因:对已经转义过的数据重复调用了inHTMLData。比如接口返回的字段本身已经存了<,你又转一次,&被转成&,最终渲染出来就是<的字面量。
解决:明确数据在链路中的状态。如果接口返回的是原始数据,在渲染层转一次;如果接口已经转过,渲染层不要再转。我一般要求后端返回原始数据,转义统一在前端做,避免两边都转导致双重转义。
4.2 现象:URL 里的参数被截断,&后面的内容丢失
原因:把uriInHTMLData的结果直接拼进href时,如果没做 HTML 属性转义,&可能被浏览器解析为实体起始符。比如?a=1&b=2里的&b可能被当成实体。
解决:拼进属性的 URL,先用uriInHTMLData处理参数值,再用inHTMLAttr处理整个 URL 字符串。两步顺序不能反:先 URI 编码,再 HTML 属性转义。
const rawParam = 'a=1&b=<script>'; const uriSafe = uriInHTMLData(rawParam); // 先 URI 编码 const attrSafe = inHTMLAttr(`/search?${uriSafe}`); // 再属性转义 // 最终拼进 href="${attrSafe}"4.3 现象:在onclick里拼用户数据,转义了还是能执行
原因:事件属性里的 JS 上下文和 HTML 属性上下文不同。inHTMLAttr处理的是 HTML 层面的引号闭合,但onclick="doSomething('用户输入')"里,用户输入如果包含'或\,可能逃逸出 JS 字符串。
解决:尽量避免在事件属性里拼用户数据。如果非要做,用inScriptData处理 JS 字符串部分,同时外层用inHTMLAttr。更好的做法是用addEventListener绑定事件,数据通过dataset传递,彻底避开内联事件。
4.4 现象:过滤后的数据在eval或setTimeout字符串里仍然执行
原因:xss-filters 防的是 HTML 注入,不是 JS 代码注入。如果你把用户输入拼进eval、setTimeout('...')、new Function里,转义 HTML 实体没用,因为 JS 引擎不认 HTML 实体。
解决:永远不要把用户输入拼进动态执行的 JS 代码。如果业务需要动态逻辑,用白名单映射,比如用户传type=1,你映射到预定义的函数,而不是直接执行用户给的字符串。
4.5 现象:某些老浏览器里转义结果异常
原因:xss-filters 依赖一些字符串 API,极老的浏览器(比如 IE8 及以下)可能不支持。虽然现在很少遇到,但如果项目有兼容要求,得测。
解决:确认目标浏览器范围。如果需要兼容 IE8,可能得引入 polyfill 或者换用更保守的手写转义函数。不过说实话,现在还在支持 IE8 的项目,XSS 可能不是最紧迫的问题。
5. 进阶用法:把 xss-filters 包成团队规范里的强制层
5.1 用 ESLint 插件卡住裸奔的 innerHTML
光靠自觉不行,我后来在团队里加了一条 ESLint 规则:检测到innerHTML、outerHTML、insertAdjacentHTML赋值时,如果右侧没有出现 xss-filters 的函数调用,就报 warning。实现方式可以写一个简单的自定义规则,或者用no-restricted-syntax匹配 AST。
// .eslintrc.js 片段(示意) module.exports = { rules: { 'no-restricted-syntax': [ 'warn', { selector: 'AssignmentExpression[left.property.name="innerHTML"]', message: 'innerHTML 赋值前请确认已用 xss-filters 处理数据' } ] } };这条规则不能百分百拦住所有情况,但能提醒开发者“这里有个安全决策点”。配合 code review,基本能堵住大部分低级错误。
5.2 写一个统一的 DOM 写入函数
更彻底的做法是封装一个safeSetHTML,内部强制走过滤:
const { inHTMLData } = require('xss-filters'); function safeSetText(el, text) { // 纯文本场景,用 textContent 最安全,不需要转义 el.textContent = text; } function safeSetHTML(el, html) { // 如果确实需要渲染 HTML,调用方必须自己保证 html 已过滤 // 这里只做一层兜底:把非字符串转成字符串 el.innerHTML = String(html); } // 对外暴露的 API 只允许传纯文本 // 需要富文本的场景走单独的审核流程逻辑说明:textContent本身就会把内容当纯文本处理,不需要额外转义,性能也好。大部分“我要显示用户输入”的场景,其实用textContent就够了,根本不需要innerHTML。只有确实要渲染带标签的内容时,才走innerHTML,并且必须经过 xss-filters 或 DOMPurify。
5.3 验证过滤是否生效的土办法
改完代码怎么确认真的防住了?我一般用几个固定 payload 手动测:
| 测试输入 | 预期结果 |
|---|---|
<script>alert(1)</script> | 页面上显示文本,不弹窗 |
"><img src=x onerror=alert(1)> | 属性不被闭合,不弹窗 |
javascript:alert(1) | 链接不可点击执行 |
' onmouseover='alert(1) | 单引号属性不被逃逸 |
把这些 payload 存成一个测试用例文件,每次改渲染逻辑就跑一遍。有条件的可以接进自动化测试,用 jsdom 模拟 DOM 环境断言输出。
5.4 什么时候该换方案
xss-filters 适合“纯文本输出”场景。如果你的业务需要允许用户提交带格式的内容(比如富文本编辑器),它就不够用了——你需要的是 DOMPurify 这类能解析 HTML 并过滤标签的库。我一般这样分:纯文本字段用 xss-filters,富文本字段用 DOMPurify,两者不冲突,按字段类型选。
从那以后我每次接新项目,第一件事就是在渲染层把 xss-filters 的函数挂上去,然后写一条 ESLint 规则卡住innerHTML。这个习惯帮我省掉了至少三次安全告警。希望帮到你。
本文还有配套的精品资源,点击获取