☰
前端XSS攻击防护实战:js-xss白名单机制与富文本净化方案
2026/10/19 5:43:18 网站建设 项目流程

1. 从一个被植入的脚本说起:XSS攻击到底有多阴险

干了这些年前端,我一直觉得XSS(跨站脚本攻击)是Web安全里最“阴险”的一类问题。它不像SQL注入那样有明确的数据库报错,也不像CSRF那样需要精心构造请求,XSS的攻击往往藏在你看不见的地方——一段不显眼的<script>标签,一个精心构造的onerror属性,甚至是一段经过编码的Unicode混淆字符。

我曾经接手过一个在线编辑器项目,用户可以在里面填写个人简介、项目描述,然后展示给其他人看。一开始大家都没当回事,直到有个用户反馈说“我打开别人的主页,浏览器卡了几秒,然后跳到一个奇怪的广告页面”。排查之后才发现,有人在一段简介里塞了完整的<img src=x onerror="fetch('恶意地址').then(...)">。由于后端存储时没有过滤,前端渲染时也没有做任何处理,这段脚本就这么在每一个浏览者的浏览器里执行了。

这个例子说明了一个核心问题:凡是用户能输入内容、又能被其他人看到的地方,都存在XSS风险。而今天要聊的js-xss,正是用来解决这类问题的一个成熟的、经得起考验的JavaScript库。它做的事情简单来说就是“消毒”——把你输入的不可信HTML字符串,按照你定义的白名单策略,过滤掉所有可能执行脚本的脏东西,输出一份安全、干净的HTML。

这篇文章适合三类人:一类是刚接触前端安全、想系统了解XSS防护的新人;一类是在项目里已经遇到XSS问题、急需落地方案的开发者;还有一类是维护过富文本编辑器、评论系统、CMS后台,想彻底把XSS漏洞堵死的老手。我会从攻击原理讲起,到js-xss的核心API、白名单配置、框架集成、常见坑位,一步步展开,保证你看完能直接在自己的项目里用起来。

2. 攻击原理深挖:为什么简单的转义不够用

2.1 XSS的三种类型与触发链路

在动手配置js-xss之前,得先把XSS的攻击模型弄清楚。常见的XSS分三种:存储型、反射型和DOM型。

存储型是最危险的,攻击者把恶意脚本提交到服务器,数据库存下来,之后每个访问页面的人都会中招。前面那个编辑器案例就属于存储型。反射型则是通过URL参数把脚本带进去,比如?search=<script>alert(1)</script>,如果后端直接把参数拼进HTML返回,就会触发。DOM型跟前两者不同,它不走服务器,纯前端通过innerHTML、document.write这类API把不可信数据插进页面时触发。

很多人以为做防护就是“转义尖括号”,把<转成&lt;、>转成&gt;就安全了。但转义方案在富文本场景下直接失效,因为你本来就希望用户提交一些格式化的HTML——加粗、链接、列表、图片。转义会把一切标签都干掉,用户写的东西全变成纯文本,产品经理第一个不答应。

2.2 真正的风险点:不是script标签,而是属性与协议

一个特别容易让新手误解的点是:XSS的核心风险往往不在<script>标签本身,而在于HTML属性和URL协议。

举个例子:

<img src="javascript:alert(1)"> <a href="javascript:evil()">点击我</a>

这种写法里没有<script>标签,但src和href这些属性里包含javascript:伪协议,浏览器照样会执行。还有事件属性:

<div onclick="alert(1)">点一下</div> <div style="background:url(javascript:alert(1))"></div>

防XSS要处理的是整个HTML语法空间里的可执行面:危险标签要干掉,危险属性要干掉,危险协议要干掉。这也是为什么“自己写几个正则过滤标签”的方案几乎必然翻车——你要考虑的情况太多了,漏一个就完蛋。

3. 为什么选js-xss:前端Sanitize方案横评与选型逻辑

3.1 主流方案对比:DOMPurify、CSP与js-xss

前端做HTML净化的主流方案大概有这几个:js-xss、DOMPurify、以及浏览器自带的CSP(内容安全策略)机制。

CSP是防御XSS的重要补充手段,它通过HTTP响应头告诉浏览器“本页面不允许执行内联脚本”“只允许从指定域名加载脚本”。CSP的优点是覆盖面广,即使某个漏洞没堵住,CSP也能兜底。但它有几个问题:配置不当会把正常业务也拦下来;而且它并不能“净化”HTML,不能解决“用户内容被展示成HTML”这个场景本身的问题。

DOMPurify也是个非常优秀的库,它以浏览器DOM为基础做净化。但js-xss有个独特优势:它是纯字符串解析,不依赖DOM环境。这意味着在服务端Node.js环境里也能直接使用,不需要jsdom模拟。对于前后端同构的项目,这一条就很舒服。

3.2 js-xss的核心优势:白名单机制的巧妙设计

js-xss的设计思路非常清晰,它不是维护一份“黑名单”(禁止哪些标签),而是维护一份“白名单”(允许哪些标签、哪些属性)。默认情况下,除了白名单里列出的基础排版标签,其余一切标签都会被视为危险品清除。

默认白名单包含的内容大致有:a、img、p、div、span、ul、ol、li、table、tr、td、th、h1~hHow6、b、strong、i、em、br、hr等,属性方面主要保留了href、title、alt、src等常用项。但注意,默认配置里href和src是经过协议检查的,javascript:这种伪协议直接被拒。

这个设计意味着:你可以放心地让用户提交富文本,同时根本不给攻击者表演的空间。白名单之外的一切都是拒绝的,这是js-xss最核心的安全哲学。

4. 上手实操:js-xss核心API与生产级配置

4.1 快速集成:三行代码完成基本净化

安装很简单:

npm install xss

然后在项目里引入并调用:

const xss = require('xss'); const userInput = '<p onclick="evil()">Hello <script>alert(1)</script></p>'; const cleanHtml = xss(userInput); console.log(cleanHtml); // 输出: <p>Hello </p>

看到没有,onclick属性被剥掉,script标签被整个移除,p标签正常保留。这就是最常见的调用方式——直接把不可信字符串丢给xss()函数,返回的就是干净字符串。

浏览器端用ES模块的方式也支持:

import xss from 'xss'; const clean = xss(dirtyHtml);

对于大多数基础使用场景,一行xss()就完了。但你要是以为这就够了,那还真不一定——默认白名单覆盖的是“通用安全基线”,到了真实业务里,需求总会变得更具体。

4.2 核心参数详解:白名单定制与自定义处理函数

js-xss最强大的地方在于它的配置项。创建一个自定义过滤器实例:

const xss = require('xss'); const options = { whiteList: { a: ['href', 'title', 'target'], img: ['src', 'alt', 'title'], p: [], div: ['class'], span: [], strong: [], em: [] }, stripIgnoreTag: true, stripIgnoreTagBody: ['script'] }; const myXss = new xss.FilterXSS(options); const cleanHtml = myXss.process(dirtyHtml);

whiteList定义的是标签白名单。每个标签对应的数组,是允许保留的属性列表。空数组表示该标签保留但不保留任何属性。注意,如果某个标签不在白名单里,整个标签连同内容都会被移除(或者根据stripIgnoreTag设置决定保留内容还是丢弃标签本身)。

再来看几个常用选项:

  • stripIgnoreTag: 设为true时,不在白名单里的标签会被剥离,但标签内部的文本内容会保留。这适合“只去标签、留文字”的场景。
  • stripIgnoreTagBody: 接受一个标签名数组,匹配的标签连内容一起全部删除。处理script、style这类标签时非常好用。
  • onTagAttr: 属性级别的自定义处理函数,可以在属性被处理前进行干预。

4.3 onTagAttr的进阶玩法:精细化属性控制

有一个真实需求:页面允许用户插入video标签,但只想允许src属性,且src必须指向站内媒体域名。这用白名单还不够,因为白名单只能控制“是否允许某个属性”,不能控制“属性值的内容”。

这时候就要用onTagAttr回调:

const options = { whiteList: { video: ['src'] }, onTagAttr: function(tag, name, value, isWhiteAttr) { if (tag === 'video' && name === 'src') { const allowedHosts = ['cdn.example.com', 'media.example.com']; try { const url = new URL(value); if (!allowedHosts.includes(url.hostname)) { return ''; } } catch (e) { return ''; } } } };

回调函数返回空字符串表示移除这个属性,返回null表示走默认流程。这种机制给了开发者极大的灵活性,相当于在js-xss的通用规则之上叠加了业务规则。

5. 场景实战:富文本编辑器与框架集成全方案

5.1 富文本编辑器场景:如何处理粘贴内容与图片链接

富文本编辑器是XSS的重灾区,因为用户可以从Word、网页、微信复制各种带格式的内容,里面混着大量危险标签。我自己在项目里遇到的典型情况是:从网页复制的文字会带上一堆内联样式,比如style="font-size:14px;color:#999",其中有的样式里还藏着background:url(javascript:...)这种追击性写法。

对于富文本内容,我的建议是设置一个“富文本专用配置”:

const richTextOptions = { whiteList: { p: ['style'], span: ['style'], strong: [], em: [], u: [], s: [], a: ['href', 'title', 'target'], img: ['src', 'alt', 'title'], ul: [], ol: [], li: [], table: ['width'], tr: [], td: ['colspan', 'rowspan', 'style'], th: ['colspan', 'rowspan', 'style'], blockquote: [], code: [], pre: [] }, onTagAttr: function(tag, name, value) { // 剥离style中的危险声明 if (name === 'style') { const sanitizedStyle = value.replace(/url\s*\(.*?\)/gi, ''); return sanitizedStyle; } } };

图片链接的校验也很重要,不能只靠白名单。攻击者用<img src="data:image/svg+xml;base64,...">直接塞SVG内容也是常见的攻击面。我在线上项目里对src做了统一校验:只允许http://、https://和站内相对路径,data:协议一律拒绝。

5.2 Vue/React中的集成:别在模板里直接渲染不可信HTML

在Vue和React里,常见的坑是把用户的HTML内容直接渲染到页面上。React里用dangerouslySetInnerHTML,Vue里用v-html,这是官方留下的后门,也是攻击者的乐园。

正确的做法是:先净化,再渲染。

React组件示例:

import xss from 'xss'; function SafeRichText({ content }) { const safeContent = xss(content, richTextOptions); return <div dangerouslySetInnerHTML={{ __html: safeContent }} />; }

Vue里的写法类似:

import xss from 'xss'; Vue.directive('safe-html', { bind(el, binding) { el.innerHTML = xss(binding.value, richTextOptions); } });

这样即使后端接口被绕过、传回了恶意内容,前端渲染时也能二次脱险。安全不是单点防御,而是多层设防。

5.3 Node.js服务端的同构实践:数据入库前先净化

我习惯在前端做一次净化,在后端入库前再做一次净化。前端的净化保证用户体验,后端的净化保证数据源头的干净。两边的配置保持一致,这样即使有人绕过前端直接打后端接口,脏数据也进不了数据库。

服务端净化示例:

const xss = require('xss'); app.post('/api/comment', (req, res) => { const cleanContent = xss(req.body.content, richTextOptions); // cleanContent 才能安全入库 });

有个容易被忽略的细节:从数据库取出内容展示时,也建议再做一次净化。因为数据可能存了好几年,当时的净化逻辑可能不够强,也可能存在历史数据本身就是脏的。展示前二次净化是兜底动作。

6. 常见问题与排查实录:踩过的坑和填坑方案

6.1 常见问题速查表

现象原因解决方案
净化后标签消失了标签不在白名单中在whiteList中显式添加
属性被移除属性不在白名单数组里在对应标签的数组里补充
javascript:链接还在onTagAttr没处理a标签的href针对href自定义协议校验
内容被截断/丢失stripIgnoreTagBody配置了该标签调整配置或改用onTag处理
嵌套多层标签后样式丢失部分标签如font不在白名单添加font标签及color、face属性
性能有明显下降在渲染循环里重复创建FilterXSS实例全局复用实例,process方法单独调用

这个表里的每一个坑我都踩过。其中最典型的两个坑,值得展开讲。

6.2 坑一:href属性的“安全漏洞”并不在href本身

很多人以为白名单里加了a: ['href']就安全了,但攻击者可以写:

<a href="java&#x73;cript:alert(1)">点我</a>

实体编码之后的javascript:依然会被浏览器解析成伪协议。js-xss默认处理了实体解码问题,会把&#x73;还原成s再做协议判断,但如果你的配置里自定义了onTagAttr覆盖了默认行为,就得自己保证协议校验。

我在项目里通常再加一道保险:

onTagAttr(tag, name, value) { if (tag === 'a' && name === 'href') { const lowerVal = value.toLowerCase(); if (lowerVal.startsWith('javascript:') || lowerVal.startsWith('vbscript:')) { return ''; } } }

6.3 坑二:配置了whiteList但属性还是被剥了

有次同事找我排查,说他在whiteList里给img标签加了class,但净化输出里class还是没了。后来发现是官方文档里的一行说明:自定义whiteList会完全覆盖默认白名单,不再与默认配置合并。

也就是说,一旦你传了whiteList,js-xss不会把默认白名单里的其他标签合并进来,而是完全用你传的这份。这既是好事,也是个容易掉进去的坑。我的建议是:基于默认白名单做扩展时,先把默认白名单导出来看看,再决定加哪些、减哪些。

官方提供了一个获取默认配置的方法:

const xss = require('xss'); // 查看默认白名单 console.log(xss.whiteList);

如果不想全部推翻,可以手动和默认配置合并,再传给FilterXSS。

7. 延伸思考:js-xss之外,你还应该做些什么

7.1 输出编码与输入校验要同时做

js-xss解决的是“不可信HTML内容展示”的问题,但它不是万能的。比如用户输入的是一个纯文本昵称,你根本不需要让它渲染HTML,那就应该用普通文本转义,而不是把字符串丢给js-xss去净化。

对于纯文本场景,正确的方式是HTML实体转义:

function escapeHtml(str) { return str.replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); }

7.2 CSP仍然要配

我见过不少团队用了js-xss之后就觉得高枕无忧了,CSP也不配了。实际上js-xss负责“不让脏活进来”,CSP负责“就算漏了一个,也能把损失降到最低”。两者是互补关系,不是一个替代另一个。

给一个基础的CSP响应头示例:

Content-Security-Policy: default-src 'self'; script-src 'self'; img-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'

script-src 'self'意味着只有同源脚本才允许执行,内联脚本一律拦截。配合js-xss,安全水位能高一个档次。

7.3 安全是持续的过程,不是一次性的补丁

js-xss本身也在更新,新版本会补充新的攻击向量,修掉旧的解析缺陷。我习惯把这个依赖放在一个独立的配置文件里,升级时专门跑一遍净化相关的测试用例,确认已有内容的展示没受影响。

另外建议团队里专门整理一份“危险输入样本集”,包含各种变形的<script>、伪协议、事件属性、编码混淆内容,每次升级依赖后跑一遍回归测试。这套做法花不了多少时间,但能挡住很多返工。

8. 最后的几点心得

做前端时间长了就会发现,XSS防护不是“配完一个库就万事大吉”的事。js-xss给了你一把好用的手术刀,但怎么切、切多深,还是取决于对整个攻击面的理解。我个人的习惯是:凡是用户可控的内容,一律按“不可信”处理;凡是渲染HTML的出口,一律走净化流程;凡是安全相关的代码改动,一律过一遍历史攻击样本回归。

有一次排查线上的一个案例,发现攻击者利用了一个第三方SDK在页面里动态插入的脚本。那个脚本内容本身不经过我们的净化管线,但它的来源域不在CSP白名单里,直接被拦了。那一刻我挺感慨的:单靠某一层防护确实不够,js-xss、服务端校验、CSP、传输层安全,每一层都不能少。

如果你现在正要给项目引入XSS防护,我的建议是:别想着一口气做到位,先让js-xss跑起来,把最核心的富文本出口管住,再逐步补白名单细节、属性校验、CSP配置。安全这件事,永远是在跟攻击者赛跑,跑起来总比原地不动强。

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

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

立即咨询