1. 为什么要在服务端做数据清理
1.1 XSS攻击是怎么发生的
跨站脚本攻击(XSS)是 Web 安全里最典型的高频漏洞之一。攻击者往页面里塞一段脚本,让它跟着正常内容一起渲染执行,就能偷 Cookie、劫持会话,甚至改写整页内容。常见形态有三种:
- 存储型:恶意代码被保存到数据库,其他用户访问页面时触发。典型场景是论坛昵称、评论、个人简介。
- 反射型:恶意代码藏在 URL 参数里,服务端没做过滤就拼接回页面,用户点开攻击者精心构造的链接就会中招。
- DOM 型:前端拿到 URL 参数或接口返回后,直接写入
innerHTML等危险位置,代码在浏览器本地被触发。
这三种形态的攻击路径不一样,但根因是同一个:程序没有区分“数据”和“代码”。用户提交的昵称、留言、搜索关键词,本质上都只是数据;一旦被拼进 HTML、JavaScript 或 URL 片段里,就可能被当作代码来解析。理解了这一点,就明白防御的核心不是去背攻击 payload,而是要让所有来自用户的内容在你设计的上下文里始终保持“数据”身份。
1.2 验证与清理是两回事
很多开发者把输入验证和输入清理混为一谈,这是安全隐患的起点。
验证解决的是“格式对不对”:isEmail()检查邮箱格式,isInt()检查整数范围,isLength()限制长度。验证不合格,直接拒绝请求。
清理解决的是“内容危不危险”:把危险的字符转义、删除,或者把富文本里不允许的标签剥掉。验证决定能不能收,清理决定收了之后是否安全。类比一下,验证像门卫查证件,清理像安检扫行李。只验证不清理,就好比人都带进来了,行李里藏了什么不管;只清理不验证,数据格式可能被改得面目全非。
在 Express-validator 里,验证函数和清理函数被设计在同一条链上,很多人写着写着就把它们混在了一起。实际项目里,我的习惯是先把验证逻辑写清楚、确认字段规则,再叠加清理逻辑,而不是凭感觉把trim()、escape()、isLength()全部无脑串起来。
1.3 为什么用 Express-validator 而不是手写正则
客户端做的任何校验都可以被绕过,服务端才是安全底线。既然要在服务端做,那就要选一个表达清晰、不易出错的方式。
手写正则的问题是维护成本太高。写一个过滤<script>的正则很简单,但攻击者可以大小写混写、插空白、换编码、用事件属性绕过。你以为自己拦截了所有危险输入,实际上只是拦住了其中的一小部分。更常见的是,项目里每个人写的正则风格都不一样,代码评审很难认出哪些是安全硬约束、哪些只是辅助规则。
Express-validator 把这些能力收敛成了中间件风格的接口。check()、body()等声明字段规则,escape()、stripLow()、blacklist()、customSanitizer()等完成清理,所有逻辑在路由定义里一眼就能看完。它不承诺能挡住所有攻击,但提供了一套结构化的方式,让你不容易漏字段、不容易忘记执行顺序。
2. 先打好基础:Express-validator 的验证链与清理链
2.1 安装与基础用法
项目里安装依赖只需要一条命令:
npm install express-validator和 Express 的集成方式非常简单。在路由中间件里声明字段规则,处理函数里通过validationResult()读取校验结果即可:
const { body, validationResult } = require('express-validator'); app.post( '/register', [ body('nickname') .trim() .isLength({ min: 2, max: 20 }) .escape() ], (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ errors: errors.array() }); } // 到这里,req.body.nickname 已经是清理后的值 res.json({ ok: true, nickname: req.body.nickname }); } );这里check()和body()都可以用来声明字段规则,body()语义上更偏向请求体,query()和params()分别处理查询参数和路径参数。check()是全字段通用入口,实际项目里按场景选择就好。
2.2 验证链与清理链的执行顺序
Express-validator 的链式调用会按你写的顺序依次执行,前一个函数的输出会作为后一个函数的输入。这个顺序直接决定结果是否正确,也是最容易踩坑的地方。
建议的顺序是“先预处理,再验证,最后清理”。比如:
body('nickname') .trim() // 1. 去掉首尾空白 .isLength({ min: 2, max: 20 }) // 2. 用干净值做长度校验 .escape() // 3. 最后做 HTML 实体转义如果反过来先escape()再isLength(),问题立刻出现。<会被转成<,长度从 1 变成 4,长度校验结果失真。更典型的是邮箱:先转义再isEmail(),@变成了@,原本合法的邮箱也会被判断为非法。
清理后的值会写回req对象对应的字段。所以在处理函数里读取req.body.nickname时,拿到的是清理后的版本。如果你不喜欢这种方式,也可以用matchedData(req, { onlyValidData: true })把经过验证和清理后的字段单独提取出来,再写入数据库或返回给前端。
2.3 别踩旧 API 的坑
网上很多老教程还在用req.sanitize()、sanitizeBody()这类接口。那是早期版本的设计,API 不在验证链上,而是直接修改请求对象,问题在于它完全不参与校验流程,也没有统一的错误处理。
从新版本开始,Express-validator 统一推荐“验证链 + 清理函数”的写法。sanitizeBody()这类方法被废弃,如果你从老项目迁移,最好一并改掉。老代码长这样:
req.sanitize('nickname').escape();对应新写法应该是:
body('nickname').escape()不只是写法变了,更重要的是规则可见性。新写法下,每一条规则都在路由定义里集中展示,评审代码的人很容易确认每个字段做了哪些处理。
3. 防止 XSS 的 5 种清理方法
3.1 方法一:trim() + escape(),最基本的组合拳
先解释一下:trim()主要解决的是数据两端的空白字符,它本身不是 XSS 的对抗手段,但它能让后续的校验和清理都基于“真正的有效内容”执行。攻击者在 payload 前后加空格绕过精确匹配的场景不少见,把空白去掉能让规则更可控。
escape()才是这组方法里的核心。它把 HTML 里有特殊含义的字符转成实体:
&变成&<变成<>变成>"变成"'变成'
转义之后,<script>在 HTML 文本里显示为<script>,浏览器不会再把它当作标签解析,而是当作普通文本展示给用户。对一个普通文本输入字段来说,这一步已经完成了绝大部分防 XSS 工作。
实际项目中最简单的组合是这样:
body('comment') .trim() .isLength({ min: 1, max: 500 }) .escape()这里的顺序还是“先 trim、再验证、最后 escape”。注意escape()在前端拿到 JSON 数据、用textContent插值的情况下可能会造成双重编码,这一点我会在第 5 章展开说,它不只是显示问题,还是设计问题。
3.2 方法二:stripLow(),清掉看不见的暗雷
stripLow()是一个容易被忽略的清理函数,它的作用是移除 ASCII 控制字符。这些字符包括 0 到 31 以及 127,简单说就是一堆看不见、打印不出来、但确实存在于字符串里的特殊控制码。
恶意输入有时候会混入控制字符来干扰拦截逻辑。比如在<script>中间插入控制字符、在文本里藏某些终端控制序列,或者利用控制字符绕过长度限制。这类内容很难一眼在日志和页面上发现,尤其当你的数据要进入数据库、日志系统或第三方接口时,控制字符可能引发各种奇怪行为。
默认情况下,stripLow()会把换行符和回车符也一并删掉。如果你的业务字段允许用户输入多行内容,要改成传true保留换行:
body('bio') .trim() .stripLow(true) .isLength({ max: 500 }) .escape()这个例子先trim(),再用stripLow(true)清理控制字符但保留换行,接着校验长度,最后转义。多行文本场景下顺序仍然非常关键:先删掉控制字符再算长度,长度校验才不会被隐藏字符干扰。
3.3 方法三:blacklist(),黑名单字符过滤
黑名单的思路是“列出不允许出现的字符,出现就删除”。比如一个字段确定不应该出现尖括号,可以这样写:
body('searchTerm') .trim() .blacklist('<>')如果用户输入how to <hack>,清理后会变成how to hack。你还可以根据业务需要把双引号、单引号、分号、百分号等加入过滤列表,但我不建议把所有防御都压在黑名单上。
黑名单有一个天然缺陷:它只认识你列出来的字符。攻击者换个编码、换个变体、或者利用未被拦截的字符组合绕过,你的规则就失效了。因此在实际项目中,黑名单适合作为辅助补充,用来确保某个字段绝不会出现特定字符,而不是当作防止 XSS 的主要武器。
这个方法的另一个问题是它会直接破坏用户内容。用户在搜索框输入C#,如果#被删掉,搜索结果完全变了。所以使用黑名单前,要确认字段约束和产品预期相符。
3.4 方法四:whitelist(),白名单字符集过滤
白名单是更安全、也更严格的做法:只保留明确允许的字符,其他字符一律删掉。比如用户名只允许字母、数字和下划线:
body('username') .trim() .whitelist('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_') .isLength({ min: 3, max: 20 })用户输入tom<script>,清理后变成tomscript,尖括号被彻底删除。注意这里我把校验放在清理之后,因为白名单已经改变了输入内容,先校验原始值没有意义。
这种“默认拒绝”的安全思维方式是最可靠的方向之一。与其拼命枚举危险输入,不如直接规定“这个字段只允许这些字符”。它的适用场景非常明确:用户名、邀请码、订单号、验证码等格式固定且不允许特殊符号的字段。
副作用同样明显:如果你在昵称字段用了这套规则,中文昵称会在清理时被全部删掉,用户会非常困惑。所以白名单策略要结合具体字段的业务约束来设计,不是所有字段都适合。
3.5 方法五:customSanitizer(),富文本场景的安全清洗
前面四种方法都有一个共同前提:这个字段是纯文本,不包含任何 HTML 标签。但很多业务场景允许用户输入富文本,比如发表带格式的评论、编写带有加粗和链接的文章。这时候直接escape()会把<b>、<a>一并转义,富文本内容就废了。
富文本防 XSS 的正解是“按标签白名单清洗”,而不是用正则去匹配<script>再删掉。现代渲染中有太多方式可以触发脚本:事件属性、JavaScript 伪协议、SVG 标签、CSS 表达式等等,正则根本写不完。
Express-validator 的customSanitizer()允许你接入自己的清洗函数。配合sanitize-html这类库,可以明确指定允许哪些标签、哪些属性:
const sanitizeHtml = require('sanitize-html'); body('content') .trim() .isLength({ max: 2000 }) .customSanitizer(value => sanitizeHtml(value, { allowedTags: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href', 'target', 'rel'] } }) )这段规则的作用是:输入必须不超过 2000 字符,经过清洗后只保留指定的排版标签;<a>标签只保留href、target、rel属性,其他属性包括onclick、onerror、style全部被剥掉。用户提交<img src=x onerror=alert(1)>,清洗后只剩下空字符串,恶意脚本根本没机会渲染。
有一个细节要特别注意:customSanitizer()执行在isLength()之后。也就是说,如果输入内容在清洗后变成空字符串、或者长度暴增,之前已经跑过的校验不会再重新执行。为了更严谨,你可以把清洗函数放在校验函数之前,然后再补一个isLength()重算。或者像我项目里的做法:先清洗,再isLength(),最后再用custom()做额外的断言。
3.6 5种方法怎么选
这些方法不是非此即彼的关系,大部分生产接口会同时用到其中两三种。选型逻辑其实很简单,核心就看字段的“允许内容边界”是什么。
| 方法 | 保护强度 | 副作用 | 典型场景 |
|---|---|---|---|
| trim() + escape() | 中 | 纯文本变实体,显示层需按已转义处理 | 评论、留言、简介 |
| stripLow() | 辅助 | 删掉控制字符,可能影响换行 | 多行文本、日志字段 |
| blacklist() | 弱 | 删除指定字符,内容被破坏 | 字段禁用特定符号 |
| whitelist() | 强 | 保留指定字符集,其他全删 | 用户名、邀请码、订单号 |
| customSanitizer() | 强 | 需要额外依赖清洗库 | 富文本、HTML 片段 |
我的习惯是:能用白名单的字段坚决用白名单;必须允许自由输入的纯文本用trim() + escape();富文本用清洗库。stripLow()和blacklist()作为辅助手段穿插在链路里,但绝不作为唯一防线。
4. 完整实操示例:一条可复现的防 XSS 请求链
4.1 准备一个最小可跑的服务
完整示例并不复杂,我直接用一个典型的“用户注册 + 评论提交”接口来说明。依赖只需要基本的三件套:
npm install express express-validator sanitize-html先看注册接口。这个接口涉及三个字段:昵称、邮箱、个人简介。三个字段的安全等级完全不同:
- 昵称:短文本,长度有限,直接用
escape()。 - 邮箱:格式严格,用
isEmail()校验,不需要escape(),因为邮箱本身就不该含 HTML 字符。 - 个人简介:可能包含多行内容,需要
stripLow(true)保留换行,再escape()转义。
代码实现如下:
const express = require('express'); const { body, matchedData, validationResult } = require('express-validator'); const sanitizeHtml = require('sanitize-html'); const app = express(); app.use(express.urlencoded({ extended: false })); const validateRegister = [ body('nickname') .trim() .isLength({ min: 2, max: 20 }) .escape(), body('email') .trim() .isEmail() .normalizeEmail(), body('bio') .trim() .stripLow(true) .isLength({ max: 200 }) .escape() ]; app.post('/register', validateRegister, (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ errors: errors.array() }); } const clean = matchedData(req, { onlyValidData: true }); // 这里 clean.nickname / clean.email / clean.bio 都是安全干净的值 res.status(201).json({ message: '注册成功', data: clean }); });我在这里额外加了normalizeEmail(),它会把邮箱统一成小写形式,从源头上减少标识重复和大小写绕过的问题。它不是防 XSS 的必需品,但属于非常实用的“数据标准化清理”。
4.2 富文本评论接口的完整链路
评论接口是存储型 XSS 的重灾区,我单独演示富文本场景:
const validateComment = [ body('content') .trim() .customSanitizer(value => sanitizeHtml(value, { allowedTags: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href', 'target', 'rel'] } }) ) .isLength({ min: 1, max: 2000 }) ]; app.post('/comment', validateComment, (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ errors: errors.array() }); } const clean = matchedData(req, { onlyValidData: true }); // clean.content 已经是经过标签白名单清洗的 HTML res.json({ html: clean.content }); });注意到这条链我刻意把customSanitizer()放在isLength()之前,让长度校验作用在清洗后的值上。提交一段恶意代码<script>alert(1)</script>,清洗后变成空字符串,然后isLength({ min: 1 })会判定为空,请求被 422 拒绝。如果先校验再清洗,空内容会被存进数据库,这显然不是我们想要的。
4.3 输出侧一定要做双保险
服务端清理做得好,不代表可以放纵输出侧。很多 XSS 漏洞即便后端存了干净值,也会在前端渲染时被重新打开。
如果你的页面用模板引擎。EJS 默认的<%= %>会转义 HTML,这是好事;但总有人为了省事用<%- %>输出不转义内容,这等于把后端的防护全部放行了。我的实践是:纯文本字段一律用<%= %>,只有经过富文本清洗、明确允许 HTML 的字段才用<%- %>。
前端拿到接口返回的 JSON 时同理。纯文本字段应该用textContent插入,而不是innerHTML。富文本字段则要确保后端已经用白名单清洗过,才可以作为 HTML 片段渲染。
纵深防御里还可以加一层内容安全策略 CSP,限制页面可以加载和执行哪些来源的脚本。它能兜住一部分绕过清理的攻击,但不要依赖它来替代数据清理。数据清理解决的是“脏数据不进系统”,CSP 解决的是“即使进来了也不能乱执行”,两者定位不同。
5. 常见问题与排查技巧实录
5.1 清理过头导致业务数据被破坏
用whitelist()后最典型的问题是:用户输入的中文、emoji、特殊字符被直接删掉,注册成功后发现昵称变成了另一串字符。这不是函数的问题,是字段边界没想清楚。
解决方案是按字段语义选择清理强度。真正受限的用户名、邀请码可以用白名单;自由输入的昵称、签名、简介用escape()和stripLow()就够了。我的习惯是在接口定义前先写一行注释说明这个字段允许什么,比如“昵称:任意 Unicode 字符,长度 2-20,输出时转义”,避免三个月后自己都忘了当初为什么这么写。
5.2 双重编码:页面里出现 <script>
这是把escape()和模板引擎默认转义叠在一起导致的。存储值已经是<script>,渲染时模板引擎又把&转成&,最终用户看到的是一段乱码文字。
排查步骤很简单:先看数据库里的原始值,再看服务端返回给模板或前端的值,最后看浏览器渲染后的效果。定位是哪个环节多转义了一次,然后把其中一层去掉。
纯文本场景下,我推荐“存储原文,输出转义”的组合。也就是说,服务端入库时不要对普通字符串做 HTML 实体转义,转义统一放在模板引擎或前端textContent那一步。这样不会有双重编码的烦恼。用escape()属于另一种可行的组合,但一旦用了,就要确保输出侧不再自动转义。两条路都能走通,选一条并贯彻到底。
5.3 嵌套对象和数组字段容易漏掉
很多接口的 body 不是扁平结构,而是{ user: { name: 'xx' }, tags: ['a', 'b'] }。如果你只用body('content')这种顶层写法,嵌套字段会完全绕过清理。解决办法是用点路径和通配符:
body('user.name').trim().escape(), body('user.bio').trim().escape(), body('tags.*').trim().escape()*匹配数组的每一项,user.name匹配嵌套字段。每次给接口加字段时,都要养成“每个字段是否已经声明清理规则”的习惯。matchedData()也只会返回显式声明的字段,没声明的基本进不来这套体系,但不代表它不存在,只是没被处理。安全加固不能只挑方便处理的字段下手。
5.4 速查表:异常现象与排查方向
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 页面执行了脚本弹窗 | 服务端未清理或输出未转义 | 抓请求,看入参和存储值是否含<script> |
页面显示<script> | 双重转义 | 检查存储值、渲染前值、模板引擎转义策略 |
| 中文昵称变成空字符串 | 白名单字符集没有包含中文 | 调整白名单或该字段改用escape() |
| 富文本图片无法显示 | 清洗白名单未包含img标签 | 确认业务是否真的需要图片,按需加标签 |
| 长度校验结果和页面显示不一致 | 清理函数和验证函数顺序颠倒 | 把trim()、清理函数放到验证之前 |
| 数组中某个对象仍能注入 | 只清理了顶层字段,未用通配符 | 检查*路径是否覆盖数组所有项 |
5.5 个人实操心得
这几年处理过不少安全加固的需求,我的体会是:防 XSS 不是某个中间件、某个函数能单独扛住的,它是一条从输入到输出的完整链路。写接口之前先想清楚每个字段最终会出现在哪个上下文里,再决定用白名单、转义还是富文本清洗。能限定字符集就尽量限定,不能限定的就统一转义,富文本单独走清洗流程。最后在输出侧把默认转义的开关注死,再加一层 CSP 兜底。这套组合在多个项目里帮我拦下过安全扫描的告警,也让线上再没出现过因为用户输入导致脚本执行的事故。