人人都会AI编程

21.1 常见 Web 攻击与防御

更新时间:2026-07-11

安全并不是一个可选的附加项。对于任何暴露在互联网上的应用,攻击者无需高超的手段,往往只需在输入框里填入一段精心构造的字符串,就可能绕过认证、窃取数据,甚至获得服务器的控制权。Node.js 生态虽然提供了大量便利的库,但也无法自动消除所有风险。理解攻击原理,并用务实的方式堵住漏洞,是后端开发者的必修课。

本节聚焦最常见的三类 Web 攻击:SQL 注入、跨站脚本(XSS)和跨站请求伪造(CSRF),逐一分析它们如何产生、代码层面如何攻击,以及在 Node.js 项目中具体的防护方案。

SQL 注入:把用户输入当成代码执行

攻击原理

SQL 注入的核心问题在于:开发者将不可信的用户输入直接拼接到 SQL 语句中,导致攻击者可以改变原始 SQL 的逻辑。 当后端将用户输入视为 SQL 语法的一部分时,攻击者就可以构造特殊输入,实现绕过认证、窃取数据、删除表,甚至执行操作系统命令。

假设有一个登录接口,验证逻辑用字符串拼接实现:

// 危险:直接拼接用户输入
const username = req.body.username;
const password = req.body.password;
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(query, (err, rows) => {
  if (rows.length > 0) {
    // 登录成功
  }
});

如果攻击者在用户名处输入 admin' --,密码随意填写,拼接后的 SQL 就变成:

SELECT * FROM users WHERE username = 'admin' --' AND password = 'xxx'

其中 -- 是 SQL 的单行注释,后面的条件全部被注释掉,仅凭用户名就能登录 admin 账号。更极端的情况下,通过 '; DROP TABLE users; -- 这样的输入可以直接删除整张表。这就是没有参数化查询带来的灾难性后果。

Node.js 中的防护方案

根本原则:永远不要将用户输入拼接到 SQL 语句中,始终使用参数化查询(预编译语句)。 无论使用的是 mysql2、pg 还是 ORM,这个原则都不可妥协。

方案一:驱动层的参数化查询

以 mysql2 为例,使用占位符 ? 传递参数:

// 安全:占位符参数化
const username = req.body.username;
const password = req.body.password;
db.query(
  'SELECT * FROM users WHERE username = ? AND password = ?',
  [username, password],
  (err, rows) => {
    // 此时 username 和 password 永远是数据,不会成为 SQL 逻辑
  }
);

驱动会将参数作为字面值处理,自动转义特殊字符,从根源上避免注入。

方案二:ORM 与查询构建器的参数化

Sequelize、TypeORM、Prisma 等 ORM 默认使用参数化查询,只要不拼接原生 SQL 字符串,注入风险就已经被基本消灭。

// Sequelize 示例:安全的查询方式
const user = await User.findOne({
  where: {
    username: req.body.username,
  }
});

但 ORM 也有“原生查询”模式,此时仍需注意:

// 危险:ORM 中拼接字符串
User.sequelize.query(
  `SELECT * FROM users WHERE id = ${req.params.id}`
);

// 安全:使用参数绑定
User.sequelize.query(
  'SELECT * FROM users WHERE id = :id',
  { replacements: { id: req.params.id } }
);

方案三:对表名、列名等动态标识符的严格白名单过滤

参数化查询只能保护“数据”部分,如果业务需要动态拼接表名或列名(如排序字段),参数化无法覆盖。此时必须使用白名单,不允许前端直接传输原始标识符:

const ALLOWED_COLUMNS = ['id', 'username', 'created_at'];
const orderBy = ALLOWED_COLUMNS.includes(req.query.orderBy)
  ? req.query.orderBy
  : 'id';
// 然后安全地拼接到 SQL 中

深度防御的其他措施

  • 最小权限原则:数据库连接账号只授予必要的权限,不使用 root 账号连接业务数据库。
  • 错误信息脱敏:生产环境不向前端返回原生数据库错误信息,避免泄露表结构线索。
  • WAF(Web 应用防火墙):在网络层对常见注入 payload 进行特征拦截,作为外部防线。

XSS (跨站脚本攻击):让其他人的脚本在你的页面运行

攻击原理

跨站脚本攻击的本质是:攻击者将恶意脚本注入到网页中,当其他用户浏览该页面时,脚本在用户浏览器中执行,从而窃取 Cookie、篡改页面或发起钓鱼。 根据注入的方式不同,XSS 通常分为存储型、反射型和 DOM 型。

典型的存储型 XSS 场景:一个论坛的评论区没有对用户输入做过滤,攻击者发布以下内容:

评论内容<script>alert('XSS')</script>

当其他用户访问这个帖子时,恶意脚本会在他们的浏览器中执行。alert 仅是最温和的演示,更真实的攻击会尝试 document.cookie 偷取会话令牌并发给第三方服务器。

反射型 XSS 则常见于搜索功能,攻击者构造一个带有恶意脚本的 URL 发给受害者,如:

https://example.com/search?q=<script>/*恶意代码*/</script>

服务端直接将未经处理的 q 参数返回在页面上,脚本被执行。

Node.js 需要防护的主要是存储型和反射型 XSS,因为 DOM 型主要发生在前端。

防护方案

核心原则:输出编码(上下文敏感的转义)

所有由用户生成并最终显示在网页上的内容,在输出前都必须进行转义。不同上下文(HTML 标签体、属性、JavaScript、CSS)需要使用不同的转义规则。

在 Node.js 后端渲染 HTML 时(如 EJS、Pug),模板引擎通常自动转义,但仍需了解其机制:

<!-- EJS 中使用 <%= %> 自动转义 HTML -->
<p>用户名:<%= user.username %></p>
<!-- 如果 username 是 <script>alert(1)</script>,渲染后变成:
     <p>用户名:&lt;script&gt;alert(1)&lt;/script&gt;</p> -->

如果需要在后端直接生成 HTML 字符串,务必使用专门的库:

const escapeHtml = require('escape-html');
const content = escapeHtml(userInput);

防御 HTTP 头加持

  • CSP(内容安全策略):通过设置 Content-Security-Policy 响应头,限制浏览器可以加载和执行的资源来源,即使恶意脚本被注入,也可能因为不属于白名单而被阻止执行。Node.js 可通过 helmet 中间件轻松配置。
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'unsafe-inline'"],
  }
}));
  • HttpOnly Cookie:将敏感的会话 Cookie 标记为 HttpOnly,这样即使存在 XSS 漏洞,document.cookie 也无法读取,大幅减少会话劫持风险。
res.cookie('sessionId', token, { httpOnly: true, secure: true });
  • X-XSS-Protection:虽然现代浏览器已弃用,但 helmet 仍会设置一些防御头。

输入校验与净化

在某些场景(如富文本编辑器),必须允许用户输入部分 HTML 标签。此时应使用 HTML 清洗库(如 DOMPurifysanitize-html),严格白名单过滤标签和属性,剔除所有脚本和事件处理器。

const sanitizeHtml = require('sanitize-html');
const dirty = '<p>按钮</p><script>alert("XSS")</script>';
const clean = sanitizeHtml(dirty, {
  allowedTags: ['b', 'i', 'em', 'strong', 'p'],
  allowedAttributes: {},
});
// 结果:<p>按钮</p>,script 被移除

前端侧防护补充

虽然本节主要讨论后端视角,但防御 XSS 需要前后端协同。前端避免使用 dangerouslySetInnerHTML(React)、v-html(Vue)或 innerHTML 直接插入不可信内容,并且在接收后端数据时也保持警惕。

CSRF (跨站请求伪造):借用你的身份发起恶意请求

攻击原理

CSRF 的攻击前提是用户已在目标网站登录并持有有效的会话凭证(Cookie)。攻击者诱骗用户访问一个恶意网页,或点击一个看起来无害的链接,这个网页会向目标网站发送一个请求(比如修改密码、转账、发表文章),而浏览器会自动附加该域下的 Cookie,使得目标网站认为这是用户本人的合法请求。

举例:银行网站 bank.com 的转账接口为:

POST /transfer
amount=1000&toAccount=attacker

如果该网站仅靠 Cookie 验证身份,攻击者可以构造一个页面:

<html>
<body>
  <form action="https://bank.com/transfer" method="POST">
    <input type="hidden" name="amount" value="5000">
    <input type="hidden" name="toAccount" value="attacker">
  </form>
  <script>document.forms[0].submit();</script>
</body>
</html>

当已登录的用户访问这个恶意页面时,表单会自动提交,银行服务器接收到请求,检测到正确的 Cookie,就会执行转账。

Node.js 中的防护方案

1. CSRF Token(同步令牌模式)

最经典的防御:服务端生成一个随机、不可预测的令牌,发送给前端(可放在 Cookie 或页面隐藏字段中)。每次发起状态变更请求时,前端必须在请求头或请求体中附带该令牌,服务端验证令牌的有效性和匹配。

在 Express 项目中,通常使用 csurfcsrf 库实现:

const csrf = require('csurf');
const csrfProtection = csrf({ cookie: true });
app.use(csrfProtection);

app.get('/form', (req, res) => {
  // 将 CSRF Token 传给模板
  res.render('send', { csrfToken: req.csrfToken() });
});

app.post('/process', (req, res) => {
  // 中间件会自动校验,不匹配会抛出 403
  res.send('提交成功');
});

前端表单需要携带 Token(通过隐藏域或请求头)。Axios 等库可配置全局拦截器从 cookie 中取 Token 并放入请求头。

2. SameSite Cookie 属性

现代浏览器支持 SameSite Cookie 属性,它可以指示浏览器在跨站请求中是否发送 Cookie。

res.cookie('session', token, {
  httpOnly: true,
  sameSite: 'strict' // 或 'lax'
});
  • Strict:完全禁止任何跨站请求附带该 Cookie,包括通过外部链接导航。用户体验可能受影响(从邮件点击链接时未登录)。
  • Lax:允许在顶级导航(如点击链接)的 GET 请求中发送,但禁止 POST 等关键操作。这是一种平衡安全与体验的推荐默认值。

SameSite 并不能 100% 取代 CSRF Token,因为早期浏览器不支持,而且某些攻击场景(如 Lax 模式下的 GET 型 CSRF)仍需 Token 防护。

3. 检验 Origin / Referer 请求头

服务端可以校验请求的 OriginReferer 头部,确保请求来自同源的可信域名。但由于某些网络环境或浏览器策略会丢失这些头,不能仅依赖此方法,可作为额外层。

function csrfCheck(req, res, next) {
  const origin = req.get('origin');
  if (origin && !origin.startsWith('https://myapp.com')) {
    return res.status(403).json({ error: '非法来源' });
  }
  next();
}

4. RESTful 风格与 Token 认证

如果项目采用纯 API 架构(前后端分离),并使用 JWT 等 Token 认证,且 Token 通过请求头 Authorization 发送而非 Cookie,则 CSRF 攻击的条件不成立,因为攻击者无法强迫浏览器附加自定义请求头。这种情况下 CSRF Token 并非必需,但仍需防范其它风险。

综合防护组合

真正的安全实践是分层防御,而非依赖单一措施:

  • 对于传统 Cookie 会话型应用:CSRF Token + SameSite=Lax + Origin 校验。
  • 对于 JWT SPA 应用:确保 token 不存储在 Cookie 中,使用 Authorization 头,并配合防 XSS。
  • 始终开启 HttpOnlySecure 标志的 Cookie,限制安全域。

小结:安全习惯的三条铁律

在完成这三种常见攻击的防护理解后,可以提炼出几条贯穿所有安全需求的铁律:

  1. 永远不信任用户的输入:SQL 参数化、HTML 转义、CSRF Token 验证,本质上都是将外部输入和内部逻辑严格隔离。
  2. 最小化攻击面:错误信息脱敏、数据库权限收紧、HTTP 头加固,任何不必要暴露的信息和权限都该移除。
  3. 利用成熟的库而非自行造轮子helmetcsurfsanitize-html 等经过社区验证的模块,远比自写的正则过滤可靠。npm audit 定期扫描依赖,及时修补已知漏洞。

安全不是一次性配置,而是贯穿开发、测试、上线、监控的持续过程。理解了攻击的原理,防御的手段也就变得清晰而不神秘。在 Node.js 应用中落实这些措施,将以最小的人力成本封堵绝大部分的常见 Web 威胁。