安全并不是一个可选的附加项。对于任何暴露在互联网上的应用,攻击者无需高超的手段,往往只需在输入框里填入一段精心构造的字符串,就可能绕过认证、窃取数据,甚至获得服务器的控制权。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>用户名:<script>alert(1)</script></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 清洗库(如 DOMPurify 或 sanitize-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 项目中,通常使用 csurf 或 csrf 库实现:
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 请求头
服务端可以校验请求的 Origin 或 Referer 头部,确保请求来自同源的可信域名。但由于某些网络环境或浏览器策略会丢失这些头,不能仅依赖此方法,可作为额外层。
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。 - 始终开启
HttpOnly和Secure标志的 Cookie,限制安全域。
小结:安全习惯的三条铁律
在完成这三种常见攻击的防护理解后,可以提炼出几条贯穿所有安全需求的铁律:
- 永远不信任用户的输入:SQL 参数化、HTML 转义、CSRF Token 验证,本质上都是将外部输入和内部逻辑严格隔离。
- 最小化攻击面:错误信息脱敏、数据库权限收紧、HTTP 头加固,任何不必要暴露的信息和权限都该移除。
- 利用成熟的库而非自行造轮子:
helmet、csurf、sanitize-html等经过社区验证的模块,远比自写的正则过滤可靠。npm audit定期扫描依赖,及时修补已知漏洞。
安全不是一次性配置,而是贯穿开发、测试、上线、监控的持续过程。理解了攻击的原理,防御的手段也就变得清晰而不神秘。在 Node.js 应用中落实这些措施,将以最小的人力成本封堵绝大部分的常见 Web 威胁。