XSS防护实战:基于js-xss的白名单过滤与安全审计指南

📅 2026/7/3 2:43:28 👁️ 阅读次数 📝 编程学习
XSS防护实战:基于js-xss的白名单过滤与安全审计指南

1. 项目概述:为什么我们需要“终极”XSS防护?

如果你做过Web开发,尤其是处理过用户生成内容(UGC)的平台,比如论坛、博客评论区或者电商的商品评价,那你一定对XSS(跨站脚本攻击)这个名词不陌生。它就像一个幽灵,潜伏在每一个未经验证的用户输入框背后。你可能听说过一些基础防护手段,比如对<script>标签进行转义,但现实中的攻击者远比我们想象的要狡猾。他们利用各种HTML、JavaScript甚至CSS的奇技淫巧,试图绕过我们脆弱的防线。今天要聊的,就是如何构建一套从“能用”到“可靠”的XSS防护体系,核心工具就是js-xss这个库,但更重要的是理解其背后的“过滤”与“审计”思想。

简单来说,XSS攻击就是攻击者将恶意脚本注入到网页中,当其他用户浏览该页面时,脚本就会在其浏览器中执行。这可能导致用户会话被盗(Cookie被窃取)、页面被篡改(挂马)、甚至以用户身份执行非法操作。而“HTML过滤”就是我们对抗XSS的第一道,也是最重要的一道防线。它的核心思想不是简单地阻止所有HTML,而是像一位严格的安检员,只放行“白名单”内安全、合规的标签和属性,对任何可疑或未知的内容,要么进行无害化处理(转义),要么直接丢弃。

js-xss模块正是这样一位优秀的“安检员”。它不像一些粗暴的过滤器,直接删除所有尖括号,而是提供了一个高度可配置的白名单机制。这意味着,对于一个富文本编辑器,我们可以允许用户使用<b><i><a href="..."><img src="...">等标签来排版和插入媒体,同时坚决拦截<script><iframe>onerror=这类高危元素和事件处理器。这种“精确打击”的能力,使得它在需要保留部分HTML格式的场景下,比单纯的转义(如将<变成&lt;)更加实用和灵活。

接下来的内容,我会带你从零开始,不仅学会如何使用js-xss,更会深入其配置原理,分享我在安全审计中积累的实战技巧和踩过的坑。无论你是前端开发者、后端工程师,还是安全爱好者,这套方法都能帮你显著提升Web应用的安全性。

2. 核心思路拆解:白名单机制的深度解析

很多初涉安全的朋友会有一个误区:防XSS就是把所有用户输入里的<>&等字符转义掉不就完了?对于纯文本显示,这确实是最简单有效的方法。但现实需求往往更复杂。比如,用户需要在评论里加粗一段文字、插入一个链接或图片,这时我们就需要允许一部分HTML标签存在。问题来了:如何区分“安全的HTML”和“恶意的脚本”?

这就是js-xss采用“白名单”机制的聪明之处。与其费尽心思去列一个永远也列不完的“黑名单”(攻击者总能找到新的绕过方式),不如反过来定义什么是“允许的”。一切不在白名单上的东西,默认都是不安全的。这个思路在安全领域被称为“默认拒绝”,是最佳实践之一。

2.1 白名单的数据结构

js-xss的白名单配置是一个JavaScript对象,结构非常直观:

{ 标签名1: ['属性名1', '属性名2', ...], 标签名2: ['属性名1', '属性名2', ...], // ... 更多标签 }

例如,一个基础的白名单可能长这样:

const basicWhiteList = { a: ['href', 'title', 'target'], b: [], i: [], img: ['src', 'alt', 'title'], p: [], br: [], span: ['class'] };

这个配置意味着:

  • 我们允许<a>标签,但只允许它拥有hreftitletarget这三个属性。如果用户输入了<a onclick="alert(1)">onclick属性会被过滤掉。
  • 允许<b><i><p><br>这些纯格式标签,且不允许它们有任何属性(数组为空)。
  • 允许<img>标签,但只能有srcalttitle属性。
  • 允许<span>标签,但只能有class属性(常用于高亮等样式)。

为什么这样设计?因为属性往往是风险的载体。href属性如果以javascript:开头,就能执行代码;src属性如果指向一个恶意脚本文件,同样危险。通过严格控制每个标签允许的属性列表,我们极大地缩小了攻击面。

2.2 过滤流程与安全边界

理解过滤流程,有助于我们在调试和审计时定位问题。js-xss的处理大致分为几步:

  1. 解析HTML:将输入的HTML字符串解析成标签、属性、文本节点等 tokens。
  2. 遍历检查:对每个 token,检查其标签名是否在白名单中。
    • :继续检查该标签的每一个属性名是否在该标签的白名单属性列表中。不在列表中的属性将被移除。
    • :该标签及其所有内容(取决于配置)将被处理。默认是进行HTML转义(例如<script>变成&lt;script&gt;),也可以配置为直接删除。
  3. 属性值净化:对于允许保留的属性,其值也可能包含恶意内容。例如,href="javascript:alert(1)"js-xss内置了对一些常见危险协议(如javascript:data:)的检查,会对这类属性值进行转义或清除。
  4. CSS过滤:如果允许了style属性,其值会通过内置的cssfilter模块进行二次过滤,防止CSS表达式等攻击。
  5. 重组输出:将所有安全的 tokens 重新组合成干净的HTML字符串输出。

这个流程构成了我们安全审计的基础。审计的核心,就是反复审视这个白名单是否“够紧”,以及过滤逻辑是否存在被绕过的可能。

3. 从安装到实战:手把手配置与使用

理论讲完了,我们动动手。假设我们正在开发一个技术博客的评论系统,需要允许用户使用一些简单的富文本格式。

3.1 环境准备与安装

首先,确保你有Node.js环境。然后通过npm安装js-xss

npm install xss

如果你在前端项目中使用,也可以通过CDN引入:

<script src="https://unpkg.com/xss@latest/dist/xss.js"></script>

安装完成后,就可以在代码中引用了:

// CommonJS const xss = require('xss'); // ES Module import xss from 'xss';

3.2 基础防护:快速上手

最直接的用法是调用xss()函数:

const dirtyHtml = '<script>alert("恶意弹窗");</script><p>这是一段正常文本。</p>'; const cleanHtml = xss(dirtyHtml); console.log(cleanHtml); // 输出: &lt;script&gt;alert(&quot;恶意弹窗&quot;);&lt;/script&gt;<p>这是一段正常文本。</p>

看到了吗?<script>标签被转义成了无害的文本,而<p>标签被保留了下来。这是因为js-xss有一个默认的白名单,包含了一些常见的、相对安全的标签,如<p><div><span><a>(但href会被检查)、<img>(但src会被检查)等,但不包含<script><iframe><style>等高风险标签。

注意:依赖默认白名单是危险的!因为它可能包含比你预期更多的标签。最佳实践是永远显式地定义自己的白名单

3.3 定义你的专属白名单

让我们为博客评论系统定义一个严格的白名单:

const commentOptions = { whiteList: { a: ['href', 'title', 'target'], // 允许链接,target用于控制是否新窗口打开 b: [], // 加粗 i: [], // 斜体 u: [], // 下划线 s: [], // 删除线 code: ['class'], // 行内代码,允许class用于语法高亮 pre: [], // 代码块 p: [], br: [], hr: [], ul: [], ol: [], li: [], blockquote: [], // 注意:暂时不允许<img>,因为需要处理图片上传和防盗链,更复杂。 }, // 不允许任何样式属性,样式通过CSS类控制 css: false, // 如果标签不在白名单上,我们选择将其内容(子节点)保留,但标签本身被转义。 // 例如 <custom>hello</custom> 会变成 &lt;custom&gt;hello&lt;/custom&gt; stripIgnoreTag: false, // 对于某些明确危险的标签,即使其内容也不保留。通常把script, style等放这里。 stripIgnoreTagBody: ['script', 'style', 'iframe', 'frame', 'link', 'meta'] }; const myXssFilter = new xss.FilterXSS(commentOptions); // 测试用例 const testInput = ` <p>大家好,我分享一个<a href="https://example.com" title="示例网站" onclick="stealCookie()">链接</a>。</p> <strong>这个标签不在白名单,会被转义</strong> <script>alert('xss')</script> <code class="language-javascript">console.log('这是一段代码');</code> `; const safeOutput = myXssFilter.process(testInput); console.log(safeOutput);

运行后,你会发现:

  • <a>标签的hreftitle被保留,但onclick属性被移除。
  • <strong>标签(不在白名单)被转义,但其内部的文本“这个标签不在白名单,会被转义”得以保留。
  • <script>标签及其内部内容被完全移除(因为它在stripIgnoreTagBody列表中)。
  • <code>标签及其class属性被保留。

实操心得:在定义白名单时,要遵循“最小权限原则”。评论系统不需要<table><form>这些复杂标签。对于<img>,要格外小心,因为它涉及外部资源加载和可能的隐私泄露(1x1追踪像素)。通常建议将图片上传到自己的服务器或可信的图床,然后以安全的链接插入。

4. 高级配置与自定义钩子:应对复杂场景

基础白名单能挡住大部分攻击,但高明的攻击者会尝试利用白名单内的标签和属性进行绕过。这时就需要用到js-xss提供的高级配置和钩子函数。

4.1 属性值的精细控制

允许<a>标签的href属性是必须的,但href的值可能是javascript:alert(1)data:text/html,<script>alert(1)</script>js-xss默认会过滤javascript:data:等危险协议,但我们可以通过onTagAttr钩子进行更精细的控制。

例如,我们希望所有外部链接都自动添加rel="noopener noreferrer"属性以防止window.opener漏洞,并且确保href是合法的HTTP/HTTPS链接或相对路径:

const advancedOptions = { whiteList: { a: ['href', 'title', 'target', 'rel'] }, onTagAttr: function (tag, name, value, isWhiteAttr) { // tag: 当前标签名,如 'a' // name: 属性名,如 'href' // value: 属性值,如 'https://example.com' // isWhiteAttr: 该属性是否在白名单内 if (tag === 'a' && name === 'href') { // 检查是否为合法URL if (!/^(https?:\/\/|#|\/)/.test(value)) { // 如果不是以 http://, https://, # (锚点), / (站内路径) 开头,则移除该属性 return ''; } // 如果是白名单属性,需要返回一个字符串来覆盖原属性 // 我们可以在这里追加 rel 属性 // 注意:这个钩子只处理当前属性,追加另一个属性需要在onTag钩子里做,更简单的方式是下面这样: } // 其他属性保持原样 return undefined; // 返回undefined表示使用默认处理逻辑 }, onTag: function (tag, html, options) { // tag: 标签名 // html: 标签的完整HTML字符串 // options: 全局选项 if (tag === 'a') { // 使用正则简单地在原有html后加上 rel 属性 // 更健壮的做法是解析html,这里仅作示例 if (!/rel\s*=/.test(html)) { return html.replace(/>$/, ' rel="noopener noreferrer">'); } } return html; } };

为什么这么做?onTagAttronTag钩子给了我们介入过滤过程的能力。onTagAttr适合对单个属性值进行验证和修改;onTag则适合对整个标签进行增删改操作。这是实现深度定制的关键。

4.2 处理CSS样式

如果业务必须允许style属性(通常不建议),css配置项就至关重要了。js-xss会使用cssfilter模块来清洗样式。

const cssOptions = { whiteList: { span: ['style'], p: ['style'] }, css: { whiteList: { 'color': true, 'background-color': true, 'font-size': true, 'text-align': true, // 不允许 position: absolute; top: 0; left: 0; 这类可能导致页面布局错乱的样式 'position': false, 'top': false, 'left': false, 'width': false, 'height': false } } }; const filterWithCSS = new xss.FilterXSS(cssOptions); const dirtyStyle = '<span style="color:red;position:absolute;left:0;top:0;z-index:9999;background:url(javascript:alert(1))">危险样式</span>'; const cleanStyle = filterWithCSS.process(dirtyStyle); console.log(cleanStyle); // 输出: <span style="color:red;">危险样式</span>

可以看到,positionlefttop等不在CSS白名单中的属性被移除了,background属性中潜在的javascript:也被清除了。强烈建议:在绝大多数情况下,应禁用style属性,通过预定义的CSS类名(如<span class="text-red">)来实现样式,这样更安全、更可控。

4.3 实战案例:从HTML中提取特定内容

安全审计有时不仅是过滤,还需要分析和提取。例如,我们需要从用户输入的HTML中安全地提取所有图片的URL,用于生成缩略图或内容预览。

const xss = require('xss'); const html = ` <p>看看我的猫:<img src="/uploads/cat.jpg" alt="我的猫" onload="alert(1)"></p> <p>还有我的狗:<img src="https://pets.com/dog.png" alt="狗"></p> <script>document.write('<img src="malicious.gif">')</script> `; const extractedImages = []; const extractOptions = { whiteList: {}, // 清空白名单,过滤掉所有标签 onTag: function (tag, html, options) { if (tag === 'img') { // 使用一个简单的正则来提取src,生产环境建议使用更严谨的解析器 const srcMatch = html.match(/src\s*=\s*["']?([^"'\s>]+)["']?/i); if (srcMatch && srcMatch[1]) { extractedImages.push(srcMatch[1]); } // 返回空字符串,表示移除这个标签 return ''; } // 对于其他所有标签,返回空字符串将其移除 return ''; }, stripIgnoreTagBody: ['script'] // 确保script标签内容也被移除 }; const filter = new xss.FilterXSS(extractOptions); const plainText = filter.process(html); // plainText 将是去除了所有标签的纯文本 console.log('提取的图片URL:', extractedImages); console.log('纯净文本:', plainText);

这个例子展示了js-xss的灵活性。我们通过一个空的whiteList和自定义的onTag钩子,实现了“只提取不保留”的功能,同时确保了处理过程的安全性,即使<img>标签里有onload这样的恶意属性,也不会被执行。

5. 安全审计实战:绕过与防御的攻防演练

仅仅配置好过滤器还不够,我们需要像攻击者一样思考,测试我们的防护是否真的牢不可破。安全审计就是一个主动发现弱点的过程。

5.1 常见XSS绕过手法与测试

以下是一些针对HTML过滤器的常见测试向量(Payload),你可以在你的过滤器中测试它们:

  1. 大小写混淆与变体

    • <ScRiPt>alert(1)</ScRiPt>
    • <img src=x onerror=alert(1)>(注意属性值没加引号)
    • <img src=x oneonerrorrror=alert(1)>(利用事件处理器名称的混淆)
  2. 利用HTML实体编码

    • 过滤器可能只解码一次实体。输入&lt;script&gt;alert(1)&lt;/script&gt;,如果过滤器错误地将其解码为<script>alert(1)</script>并输出,就会导致问题。但js-xss默认会正确处理。
  3. 利用JavaScript协议

    • <a href="javascript:alert(document.domain)">点击</a>
    • <a href="JAVASCRIPT:alert(1)">点击</a>(大小写)
    • <a href="java script:alert(1)">点击</a>(插入制表符)
    • js-xss默认会过滤javascript:协议,但需要测试data:vbscript:等。
  4. 利用标签属性分割

    • <img src="x" onerror="alert(1)"(缺少闭合引号和>,依赖浏览器容错)
    • <img src=xonerror=alert(1)>(属性被空格或换行分割)
  5. 利用CSS表达式(旧版IE)或SVG/HTML5新特性

    • <div style="background:url(javascript:alert(1))">
    • <svg><script>alert(1)</script></svg>
    • <details open ontoggle=alert(1)>(HTML5事件)

审计方法:构建一个测试页面,将用户输入经过你的js-xss过滤器处理后,直接插入到DOM中(例如innerHTML),然后观察是否弹窗、是否发起了非法网络请求(可通过浏览器开发者工具Network面板查看)、DOM结构是否被意外修改。

5.2 使用靶场进行系统性测试

手动测试效率低。建议使用专门的XSS靶场,如“皮卡丘(Pikachu)靶场”、“DVWA (Damn Vulnerable Web Application)”或“XSS Labs”。这些靶场预设了各种存在XSS漏洞的场景和过滤条件,你可以将js-xss作为防护层插入,看是否能拦截所有攻击向量。

例如,在测试时,可以这样封装你的过滤函数:

// server.js (Node.js后端示例) const express = require('express'); const xss = require('xss'); const app = express(); app.use(express.urlencoded({ extended: true })); const myFilter = new xss.FilterXSS(customOptions); app.post('/comment', (req, res) => { const userContent = req.body.content; const safeContent = myFilter.process(userContent); // 将safeContent存入数据库 // ... // 在响应中返回处理后的内容,用于测试 res.send(`<h3>预览(安全渲染):</h3><div>${safeContent}</div>`); }); app.listen(3000);

然后使用Burp Suite、OWASP ZAP等工具,或编写脚本,向/comment接口发送包含上述各种测试Payload的请求,检查返回的HTML中是否还有可执行的恶意代码。

5.3 审计清单与配置复查

定期对照以下清单检查你的js-xss配置:

检查项安全配置建议风险说明
白名单是否最小化仅开放业务必需的标签和属性。多余的标签会增加攻击面。
<a>标签的href是否严格限制了协议(仅http/https/mailto/#)?是否验证了URL格式?防止javascript:data:等协议执行代码。
<img>标签的src是否限制了域名(只允许本站或可信CDN)?是否防止了<img src=1 onerror=...>攻击?onerror等事件处理器必须被过滤。
style属性是否已禁用?如果启用,CSS白名单是否足够严格?CSS中可包含expression()url(javascript:)等攻击向量。
on*事件处理器确保白名单中没有任何标签允许onclickonloadonerror等属性。这是最常见的XSS注入点之一。
target属性如果允许,是否应强制为_blank并配合rel="noopener noreferrer"防止window.opener漏洞。
stripIgnoreTagstripIgnoreTagBody是否根据需求正确配置?对于scriptstyle等,应使用stripIgnoreTagBody错误的配置可能导致恶意代码被转义而非移除。
自定义钩子逻辑检查onTagAttronTag中的逻辑是否有缺陷,是否可能被绕过?自定义逻辑可能引入新的漏洞。

6. 与其他安全措施形成纵深防御

js-xss是客户端和服务器端输入过滤的利器,但绝不能作为唯一的安全措施。真正的安全需要多层防护,形成纵深防御。

  1. 输入验证:在过滤之前,先进行严格的输入验证。例如,检查长度、格式(是否是预期的HTML片段)、字符集等。这可以在恶意数据进入业务逻辑前就将其拒之门外。
  2. 输出编码:即便使用了js-xss,在将内容输出到不同上下文时,也要进行编码。
    • 输出到HTML正文:使用js-xss过滤后直接插入是安全的。
    • 输出到HTML属性:例如<input value="<%= userInput %>">,即使userInput经过js-xss过滤,也应进行HTML属性编码(将"转义为&quot;)。js-xss处理的是整个HTML片段,对于这种嵌入到属性值中的场景,需要额外的编码。
    • 输出到JavaScript:务必使用JSON.stringify()进行编码,而不是简单拼接。
    • 输出到URL参数:进行URL编码。
  3. 内容安全策略:这是现代浏览器提供的一道强力后防线。通过设置HTTP头Content-Security-Policy,你可以告诉浏览器只允许加载来自特定来源的脚本、样式、图片等。即使攻击者成功注入了恶意脚本,如果该脚本的来源不在CSP允许列表中,浏览器也不会执行它。
    Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline';
    这个策略表示:默认只允许同源资源;脚本只允许同源和https://trusted.cdn.com;样式允许同源和内联样式(‘unsafe-inline’通常不建议,这里仅为示例)。
  4. 设置安全的Cookie属性:为会话Cookie设置HttpOnlySecure属性,防止通过JavaScript窃取(HttpOnly),并确保只在HTTPS连接中传输(Secure)。
  5. 使用现代前端框架:React、Vue、Angular等框架默认提供了良好的XSS防护,因为它们通常使用文本插值({{ data }})或安全的DOM API(如textContent)来更新内容,而不是innerHTML。但要注意,当使用v-html(Vue)或dangerouslySetInnerHTML(React)时,仍然需要js-xss这样的过滤器。

js-xss作为你安全链条中处理可信但需要净化的HTML输入的一环,结合上述其他措施,才能构建起真正坚固的Web应用防线。安全不是一次性的工作,而是需要持续关注、审计和更新的过程。定期复查你的过滤规则,关注js-xss库的更新日志,了解新的XSS攻击手法,才能让你的防护始终处于有效状态。