Web 应用的安全问题往往源于对用户输入的不信任不彻底。Node.js 服务端同样面临所有经典攻击手段的威胁,而且由于 JavaScript 的灵活性和 npm 生态的复杂性,一些攻击向量甚至会被放大。本节聚焦于 Node.js Web 服务中最常见的五种攻击方式:SQL 注入、XSS、CSRF、文件上传漏洞和路径遍历漏洞,并给出可直接落地的防御方案。
21.1.1 SQL 注入
攻击原理
SQL 注入是最古老却依然频繁出现的漏洞,根源在于使用字符串拼接的方式构造 SQL 语句。攻击者通过在输入中嵌入恶意 SQL 片段,能够窃取、篡改或删除数据库数据。
假设一个登录接口的后端代码如下:
// 危险的拼接方式
const username = req.body.username;
const password = req.body.password;
const sql = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(sql, (err, result) => {
// ...
});
攻击者可以提交 username 为 ' OR 1=1 --,最终执行的 SQL 变为:
SELECT * FROM users WHERE username = '' OR 1=1 --' AND password = '...'
OR 1=1 永远为真,-- 注释掉后续条件,攻击者直接绕过了身份验证。
防御方案
参数化查询(预编译语句)是唯一正确的解决路径。所有支持 Node.js 的数据库驱动都提供了参数化接口,它会把用户输入当作数据而非代码来解析,从根本上杜绝 SQL 注入。
使用 mysql2 的参数化查询:
// 安全的参数化查询
const username = req.body.username;
const password = req.body.password;
const sql = 'SELECT * FROM users WHERE username = ? AND password = ?';
db.query(sql, [username, password], (err, result) => {
// ...
});
使用 ORM 时同样需要避免拼接原生 SQL。即使必须使用动态表名或列名(这些无法参数化),也应该通过白名单校验,而不是直接引用用户输入。
// 如果动态列名不可避免,使用白名单
const ALLOWED_COLUMNS = ['id', 'username', 'email', 'create_time'];
const sortBy = req.query.sortBy;
if (!ALLOWED_COLUMNS.includes(sortBy)) {
throw new Error('Invalid column');
}
// 然后安全地拼接(此时已经是受控值)
const sql = `SELECT * FROM users ORDER BY ${sortBy}`;
21.1.2 跨站脚本攻击(XSS)
攻击原理
XSS 攻击允许攻击者将恶意脚本注入到其他用户浏览的页面中,达到窃取 Cookie、劫持会话、篡改页面内容等目的。根据注入方式,XSS 可分为存储型(恶意数据存入数据库后被展示)、反射型(参数直接回显)和 DOM 型(前端处理不当)。
Node.js 服务端通常承担模板渲染或 API 数据输出的职责,如果直接返回未经转义的用户内容,就会产生漏洞。例如:
// 危险:直接输出用户输入的评论内容
res.send(`<div>${comment.content}</div>`);
攻击者提交的评论如果包含 <script>alert('XSS')</script>,这段脚本就会在访问页面的其他用户浏览器中执行。
防御方案
上下文相关的输出转义是防御 XSS 的核心原则。
- HTML 实体转义:在 HTML 上下文中输出数据时,必须将
<转为<、>转为>、"转为"等。不要手动实现转义函数,使用成熟的库如escape-html或模板引擎自带的转义机制。
使用 escape-html 包:
const escapeHtml = require('escape-html');
res.send(`<div>${escapeHtml(comment.content)}</div>`);
多数 Node.js 模板引擎(如 EJS、Pug、Handlebars)默认会对变量进行 HTML 转义,但需要确认 {{ 和 {{{ 的区别(后者通常跳过转义),并尽量避免使用原始 HTML 输出。
- 内容安全策略(CSP):设置 HTTP 响应头
Content-Security-Policy可以限制浏览器只从可信源加载资源,即使注入了一小段脚本也难以真正执行。可以使用helmet中间件快速配置。
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'trusted-cdn.com']
}
}));
- Cookie 安全属性:为会话 Cookie 设置
HttpOnly(禁止 JavaScript 读取)、Secure(仅 HTTPS 传输)和SameSite(限制跨站请求携带),可以极大削弱 XSS 得手后的影响。
21.1.3 跨站请求伪造(CSRF)
攻击原理
CSRF 利用用户已登录的身份,在用户不知情的情况下发起恶意请求。例如,用户登录了银行网站 A,浏览器中存有 A 的 Cookie。然后他访问了一个恶意网站 B,B 中的代码会自动向 A 提交转账请求,由于浏览器会自动携带 A 的 Cookie,转账请求就带着用户的身份成功执行了。
对于 Node.js 后端来说,CSRF 的典型特征就是“正常携带了认证 Cookie 的写操作请求”,区分不出是用户本人发起还是恶意网站伪造。
防御方案
同步令牌模式(Synchronizer Token Pattern) 是最经典的防御手段,也是 Node.js 框架中最常用的方式。
- 原理:服务端生成一个随机令牌(CSRF Token),在前端表单中作为隐藏字段或放入请求头中。提交请求时,服务端校验该令牌是否与用户会话中的一致。因为恶意网站无法获取该令牌(同源策略限制),所以伪造请求无法通过校验。
使用 csurf 中间件(Express 系):
const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.get('/form', csrfProtection, (req, res) => {
// 将 token 传递给前端
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/process', csrfProtection, (req, res) => {
// 如果 token 无效,中间件会自动返回 403
res.send('操作成功');
});
前端表单中需加入隐藏字段:
<form method="POST" action="/process">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<!-- 其他字段 -->
</form>
- SameSite Cookie:这是辅助方案但非常有效。设置会话 Cookie 的
SameSite属性为Strict或Lax,可以阻止浏览器在跨站请求中携带 Cookie,从源头上阻断 CSRF。注意该属性在某些老版浏览器中不支持,仍然需要 Token 方案兜底。
res.cookie('sessionId', 'xxx', {
httpOnly: true,
secure: true,
sameSite: 'strict' // 或 'lax'
});
- 校验 Referer/Origin 头:可以作为低成本补充,但不应作为唯一防线,因为这些头部在某些网络环境下可能缺失或被修改。
21.1.4 文件上传漏洞
攻击原理
文件上传功能如果不加限制,可以让攻击者上传可执行脚本(如 PHP、JSP、甚至 .js 文件通过文件包含或目录遍历执行)或超大文件耗尽磁盘空间。即便在 Node.js 环境中,如果配置了静态资源目录指向上传目录,攻击者上传一个 .html 文件也可能实施存储型 XSS;上传一个 .node 原生扩展模块在某些极端配置下也可能被 require 加载。常见的攻击向量包括:
- 上传可执行后门:如
.php、.jsp,虽然 Node.js 本身不会解析,但如果前面搭配了 Nginx 且配置不当,可能传递给其他后端。 - 绕过前端校验:仅靠文件扩展名后缀校验,攻击者可以通过
Malware.pdf.jsp或修改 MIME 类型绕过。 - 路径遍历文件名:如上传的文件名是
../../../etc/cron.d/evil,可能覆盖系统文件。 - 内容注入:上传 SVG 文件中包含
<script>标签,被当做图片展示时执行 XSS。
防御方案
所有上传文件必须被视为不受信任的输入,采取多层防护:
- 文件类型白名单:仅允许业务需要的文件类型,如
image/jpeg、application/pdf。绝不能仅依赖后缀名或客户端 MIME 类型,应同时检查文件扩展名和真实的文件魔术数字(magic numbers)。
使用 file-type 库读取文件头判断真实类型:
const FileType = require('file-type');
const buffer = fs.readFileSync(uploadedFilePath);
const type = await FileType.fromBuffer(buffer);
if (!type || !['jpg', 'png', 'pdf'].includes(type.ext)) {
throw new Error('不允许的文件类型');
}
- 文件内容扫描:对于图片、文档等类型,可以使用 ClamAV 等工具扫描病毒;对于 SVG 等基于 XML 的文件,需要过滤掉脚本标签。
- 安全的文件存储与访问:
- 将上传文件存储在 Web 根目录之外的专用存储中,绝不能直接放置在静态资源路径下。
- 为文件生成随机的存储名称(UUID),保留原始文件名作为元数据,不直接用于文件系统路径。
- 如果必须使用云存储,采用预签名 URL 等方式限制访问权限。
- 限制上传文件大小(通过
multer等中间件配置limits.fileSize)。
使用 multer 的基础安全配置:
const multer = require('multer');
const upload = multer({
storage: multer.diskStorage({
destination: '/var/uploads/', // 非 Web 可访问目录
filename: (req, file, cb) => {
const uniqueName = uuidv4() + path.extname(file.originalname);
cb(null, uniqueName);
}
}),
limits: {
fileSize: 5 * 1024 * 1024 // 限制 5MB
},
fileFilter: (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'application/pdf'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('不支持的文件类型'));
}
}
});
- 禁止执行权限:确保上传目录不被赋予执行权限,且 Web 服务器不会将上传目录作为脚本执行路径。
21.1.5 路径遍历漏洞
攻击原理
路径遍历(Path Traversal)发生在应用使用用户输入构造文件路径时,未做充分过滤,使攻击者能够通过 ../ 序列跳出预期目录,读取或写入任意文件。典型例子:一个文件下载接口接受 filename 参数,后端直接拼接路径去读取文件。
// 危险:直接拼接文件名
app.get('/download', (req, res) => {
const filename = req.query.filename;
const filePath = path.join('/app/public/files', filename);
res.download(filePath);
});
攻击者可以请求 /download?filename=../../../etc/passwd,最终路径变为 /app/public/files/../../../etc/passwd,即 /etc/passwd,导致系统敏感文件泄露。
防御方案
规范化并验证最终路径是否仍在预期目录内是标准的防御方法。
const path = require('path');
const fs = require('fs');
app.get('/download', (req, res) => {
const filename = req.query.filename;
// 预期的基准目录
const baseDir = path.resolve('/app/public/files');
const targetPath = path.resolve(baseDir, filename);
// 关键:确保 targetPath 以 baseDir 开头
if (!targetPath.startsWith(baseDir + path.sep)) {
return res.status(403).send('禁止访问');
}
// 额外检查文件是否存在
if (!fs.existsSync(targetPath)) {
return res.status(404).send('文件不存在');
}
res.download(targetPath);
});
要点说明:
path.resolve会解析掉../,因此targetPath将是最终的绝对路径;如果恶意传入../../../etc/passwd,就会落在预期目录之外。- 判断前缀时,请务必加上
path.sep(或使用startsWith+ 长度判断),否则类似/app/public/files-other/file这样的路径可能绕过检查。 - 永远不要直接使用用户输入作为文件名或路径片段,如果一定需要文件名映射,应使用白名单映射(如传入 ID,后端查表得到真实文件名)。
- 同样,在归档解压等功能中也要检查解压路径,防止压缩包内包含
../../路径的攻击(Zip Slip 攻击)。
21.1.6 安全防护的组合思维
上述五种攻击并非孤立存在,真实攻击往往组合运用。例如,一个同时存在文件上传漏洞和路径遍历漏洞的系统,攻击者可以先上传含有恶意脚本的 .txt 文件,再利用路径遍历将其包含执行。因此安全防御要采用纵深防护策略:
- 对所有输入进行严格校验与过滤,假设所有用户数据都是恶意的。
- 最小化权限原则:数据库账户只授予必要权限;文件系统权限只给必需的应用用户。
- 设置安全响应头:使用
helmet快速启用 CSP、防止 MIME 嗅探、禁止页面嵌入等。 - 定期依赖扫描:
npm audit和第三方工具可以检测引入的库是否存在已知漏洞。 - 日志与监控:记录安全事件,设置异常行为告警,及时响应。
Node.js 本身并未提供一站式安全解决方案,但其灵活的中间件机制让搭建安全防线变得直接而清晰。掌握这些攻防原理,才能在构建高并发 Web 服务的同时,守住数据与用户的安全底线。