人人都会AI编程

21.1 常见 Web 攻击与防御

更新时间:2026-07-11

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 上下文中输出数据时,必须将 < 转为 &lt;> 转为 &gt;" 转为 &quot; 等。不要手动实现转义函数,使用成熟的库如 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 属性为 StrictLax,可以阻止浏览器在跨站请求中携带 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。

防御方案

所有上传文件必须被视为不受信任的输入,采取多层防护:

  1. 文件类型白名单:仅允许业务需要的文件类型,如 image/jpegapplication/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('不允许的文件类型');
}
  1. 文件内容扫描:对于图片、文档等类型,可以使用 ClamAV 等工具扫描病毒;对于 SVG 等基于 XML 的文件,需要过滤掉脚本标签。
  1. 安全的文件存储与访问
  • 将上传文件存储在 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('不支持的文件类型'));
    }
  }
});
  1. 禁止执行权限:确保上传目录不被赋予执行权限,且 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 服务的同时,守住数据与用户的安全底线。