Node.js后端接收前端传来的数据时,最常见的漏洞往往不是SQL注入,而是那些看似无害的特殊字符和不可见控制符。一个用户昵称里带的零宽空格、一段文本里混入的ANSI转义序列、或者表单字段中夹带的换行符,都可能在下游系统引发连锁反应。清洗不是简单调一个库就能解决的事,需要根据数据的最终去向分层处理。
先明确数据最终会流向哪里清洗策略完全取决于数据用途。如果数据最终会写入MySQL,重点要处理反斜杠、单引号、双引号和NULL字节;如果数据会进入MongoDB,美元符号$和点号.是必须警惕的;如果数据会拼接到Shell命令或文件路径中,分号、管道符、反引号和空格才是核心威胁;如果数据会回显到HTML页面,尖括号、引号和&符号则是XSS的入口。很多人一上来就用一个通用过滤函数,结果该防的没防住,不该过滤的又把正常用户输入搞坏了。正确的做法是先给每个输入字段标注它的下游用途,再决定用哪一套清洗规则。
控制字符和不可见字符是重灾区普通开发者容易忽略控制字符。ASCII码0到31之间的字符,以及127的DEL字符,在日志、终端和某些协议中都有特殊含义。比如字符0x1B是ESC,后面跟一串字符就能构成ANSI转义序列,攻击者可以用它伪造终端输出、注入恶意命令到日志查看器。字符0x00是NULL字节,很多底层C函数遇到它会截断字符串,但在JavaScript字符串里它却能完整存在,这就造成了语言层面的不一致。还有Unicode里的零宽字符,像U+200B零宽空格、U+200C零宽非连接符、U+200D零宽连接符,肉眼完全看不见,但能绕过很多基于关键词的过滤系统,也能让两个看起来一样的用户名在数据库里成为不同记录。清洗的第一步应该是移除或转义所有控制字符,只保留真正需要的空白字符。
用白名单还是黑名单黑名单的思路是列出所有已知的恶意字符然后过滤掉,但攻击者总能找到新的编码方式绕过。Unicode有上百万个码点,同一种语义可以用多种方式表示,比如斜杠/可以用全角斜杠/、U+2215除法斜杠、U+2044分数斜杠来表示。黑名单永远列不全。白名单则反过来,只允许明确安全的字符通过。对于用户名,可以只允许字母、数字、下划线和连字符;对于手机号,只允许数字和加号;对于金额,只允许数字和小数点。白名单的维护成本低得多,安全性也高得多。实际项目中,能确定字段格式的都用白名单,只有自由文本输入才退而使用黑名单加转义。
转义和移除是两回事很多教程把转义和移除混为一谈。移除是把危险字符直接删掉,转义是把危险字符转换成安全形式保留下来。比如用户输入了一段包含<script>的文本,如果这个文本最终要显示在HTML页面上,移除会破坏用户原本想表达的内容,转义成<script>则既安全又保留了原始信息。但如果这个文本是要写入CSV文件,转义规则又不一样,双引号需要写成两个双引号,换行符需要用引号包裹。转义必须针对最终输出环境来做,在入口处做HTML转义,到了CSV导出时反而会出问题。正确的架构是入口只做基础清洗,出口做针对性的转义。
Node.js里可用的清洗工具最基础的是用validator.js这个库,它提供了isEmail、isAlphanumeric、isLength等大量校验函数,还有escape、stripLow、stripTags等清洗函数。stripLow可以移除控制字符,stripTags可以移除HTML标签。但要注意stripTags的默认实现并不完美,遇到畸形的HTML结构可能漏掉标签,真正可靠的HTML消毒需要用sanitize-html这个库,它基于解析器而不是正则表达式,能正确处理嵌套标签和属性。
const sanitizeHtml = require('sanitize-html');
const validator = require('validator');
function cleanUserInput(rawInput, fieldType) {
// 第一步:移除所有控制字符和不可见Unicode字符
let cleaned = validator.stripLow(rawInput);
cleaned = cleaned.replace(/[\u200B-\u200D\uFEFF]/g, '');
// 第二步:根据字段类型做白名单过滤
switch(fieldType) {
case 'username':
cleaned = cleaned.replace(/[^a-zA-Z0-9_\-]/g, '').slice(0, 32);
break;
case 'phone':
cleaned = cleaned.replace(/[^0-9+]/g, '').slice(0, 20);
break;
case 'freeText':
cleaned = sanitizeHtml(cleaned, {
allowedTags: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
allowedAttributes: {}
});
break;
default:
cleaned = cleaned.slice(0, 500);
}
return cleaned;
}
上面的代码展示了一个分层清洗的思路。stripLow先处理控制字符,正则移除零宽字符,然后根据字段类型走不同的白名单规则。freeText字段用sanitize-html做白名单标签过滤,只允许少数几个安全的排版标签,所有属性一律禁止。最后加一个长度截断,防止超长输入消耗下游资源。
正则表达式注入的风险如果你的代码里用用户输入来动态构造正则表达式,那用户输入里的反斜杠、方括号、圆括号、星号、加号、问号都可能改变正则的语义。比如用户输入a(b,你直接new RegExp(userInput)就会抛异常,因为括号没有闭合。更危险的是用户输入(?!)这样的结构,可能让正则匹配行为完全失控。处理办法是先把用户输入里的所有正则特殊字符用反斜杠转义,或者直接用indexOf、includes这类字符串方法替代正则匹配。
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 安全地检查用户输入是否包含某个关键词
const userInput = 'a(b*c)';
const keyword = 'b*c';
const safePattern = new RegExp(escapeRegExp(keyword));
console.log(safePattern.test(userInput));
这个escapeRegExp函数把正则表达式里所有具有特殊含义的元字符都加上了反斜杠,让它们变成普通字符。如果keyword来自用户输入,不转义直接放进RegExp构造函数,用户输入一个*号就会让匹配逻辑完全变样。
JSON注入和换行符问题很多Node.js应用会把用户输入写入日志文件,或者拼接到JSON字符串里。如果用户输入里包含换行符,日志系统可能被伪造。攻击者可以提交一段包含\n[2024-01-01 00:00:00] ERROR: fake log entry的文本,让日志里出现伪造的记录。解决办法是在写日志前把所有换行符替换成空格或转义序列。JSON注入则是用户输入里包含双引号和反斜杠,如果用手动拼接的方式构造JSON字符串,这些字符会破坏JSON结构。正确做法是永远用JSON.stringify来序列化数据,不要手动拼接。
const userData = {
name: req.body.name,
comment: req.body.comment
};
// 错误做法:手动拼接JSON
// const jsonStr = '{"name":"' + userData.name + '","comment":"' + userData.comment + '"}';
// 正确做法:用JSON.stringify
const jsonStr = JSON.stringify(userData);
// 写日志前清洗换行符
const logEntry = JSON.stringify({
timestamp: new Date().toISOString(),
user: userData.name.replace(/[\r\n]+/g, ' '),
action: 'login'
});
console.log(logEntry);
JSON.stringify会自动处理双引号、反斜杠、换行符等所有需要转义的字符,生成的字符串永远是合法的JSON。手动拼接JSON是很多安全漏洞的根源,即使做了转义也容易遗漏边界情况。
数据库层面的参数化查询清洗用户输入不能替代参数化查询。即使你在应用层把所有单引号都转义了,数据库驱动层面的参数绑定仍然是必须的。Node.js的mysql2、pg、mongodb驱动都支持参数化查询或绑定参数。参数化查询的原理是把SQL语句的结构和数据分开传输,数据库引擎不会把参数值当作SQL代码解析,这样无论参数里包含什么字符都不会破坏查询结构。
const mysql = require('mysql2/promise');
async function getUserByUsername(username) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// 参数化查询:?是占位符,username作为数据传入
const [rows] = await connection.execute(
'SELECT id, username, email FROM users WHERE username = ?',
[username]
);
await connection.end();
return rows;
}
注意这里的username没有经过任何手动转义,直接作为参数传给execute方法。mysql2驱动会在协议层面处理所有特殊字符,这是最安全的做法。如果在这个基础上再手动加一层转义,反而可能因为双重转义导致数据损坏。
文件路径和命令执行的特殊处理如果用户输入会参与文件路径构造,比如上传文件的文件名、或者用户指定的导出路径,需要额外处理路径穿越。攻击者提交../../etc/passwd这样的路径,可能读取到服务器上的任意文件。Node.js的path模块提供了normalize和resolve方法,但光靠它们不够。最可靠的做法是用path.basename提取文件名部分,丢弃所有目录信息,然后检查结果是否包含..或空字符串。
const path = require('path');
function safeJoinPath(baseDir, userProvidedName) {
// 只取文件名部分,丢弃任何目录结构
const fileName = path.basename(userProvidedName);
// 检查是否包含危险字符
if (fileName === '' || fileName === '.' || fileName === '..' ||
fileName.includes('\0') || fileName.includes('/') || fileName.includes('\\')) {
throw new Error('Invalid file name');
}
const fullPath = path.join(baseDir, fileName);
// 最终确认路径确实在baseDir内部
const resolvedBase = path.resolve(baseDir);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase + path.sep)) {
throw new Error('Path traversal detected');
}
return fullPath;
}
这段代码做了三层防护:basename去掉目录部分,检查残留的危险字符,最后用resolve加startsWith确认最终路径没有逃出基准目录。如果用户输入会拼接到Shell命令里,最好的做法是完全避免使用child_process.exec,改用execFile或spawn,它们不会启动Shell,因此分号、管道符、反引号这些Shell特殊字符都不会被解释。
Unicode规范化防止同形异义攻击Unicode里有大量看起来一样但码点不同的字符。比如拉丁字母a和西里尔字母а在屏幕上几乎无法区分,但它们在字符串比较中是不同的。攻击者可以利用这一点注册一个看起来像admin的用户名,或者绕过基于字符串匹配的内容过滤。解决办法是在清洗流程中加入Unicode规范化,用String.prototype.normalize方法把字符转换成标准形式。NFC是组合形式,NFKC还会做兼容性转换,把全角字符转成半角。
function normalizeInput(str) {
// NFKC规范化:全角转半角,兼容字符转标准形式
return str.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
}
const input1 = 'admin'; // 普通拉丁字母
const input2 = 'аdmin'; // 第一个字母是西里尔字母
console.log(input1 === input2); // false
console.log(normalizeInput(input1) === normalizeInput(input2)); // 仍然false,但至少消除了全角混淆
NFKC规范化能把全角字母、数字转成半角,把一些兼容字符转成标准形式,但无法解决不同字母系统之间的同形问题。对于用户名这类关键标识符,最稳妥的做法是在白名单过滤时只允许ASCII字母和数字,这样西里尔字母、希腊字母等直接就被过滤掉了。
清洗的时机和架构设计很多团队在Controller层做清洗,每个接口都写一遍清洗逻辑,导致代码重复且容易遗漏。更好的做法是在数据进入业务逻辑之前统一清洗,可以用中间件或装饰器模式。Express和Koa都支持中间件,可以在路由处理之前对req.body、req.query、req.params做统一的基础清洗。但要注意,基础清洗只做通用处理,比如移除控制字符、限制长度、做Unicode规范化。针对具体字段的白名单校验和业务规则校验,应该放在业务逻辑层或者用专门的校验库如Joi、zod来做。
const express = require('express');
const app = express();
// 全局清洗中间件
app.use((req, res, next) => {
if (req.body) {
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'string') {
req.body[key] = req.body[key]
.normalize('NFKC')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.slice(0, 10000);
}
}
}
next();
});
app.post('/api/user', (req, res) => {
const username = req.body.username;
// 这里再做字段级白名单校验
if (!/^[a-zA-Z0-9_]{3,32}$/.test(username)) {
return res.status(400).json({ error: 'Invalid username' });
}
// 业务逻辑...
});
全局中间件处理了所有字符串字段的通用清洗,包括Unicode规范化、控制字符移除和长度限制。具体的字段格式校验放在路由处理函数里,用正则白名单做最终把关。这种分层方式让清洗逻辑清晰可维护,也不会因为全局规则太严格而破坏某些特殊字段的合法内容。
不要过度清洗清洗的目的是保证数据安全,不是把数据改得面目全非。有些团队看到用户输入里有单引号就删掉,有尖括号就删掉,有分号就删掉,结果正常用户输入英文缩写、数学表达式、代码片段时全被破坏了。过度清洗会严重影响用户体验,而且并不能真正提高安全性。安全的关键在于正确的转义和参数化,而不是在入口处把一切看起来可疑的字符都删光。记住一个原则:入口处只做基础清洗和格式校验,出口处做针对性的转义,数据库访问用参数化查询,文件操作做路径校验,命令执行避免使用Shell。每一层各司其职,整体安全性才有保障。
