Node.js Web 安全
Node.js JWT 身份认证 解决了身份问题,但接口上线后还有一堆攻击等着。这篇讲最常见的几种威胁,SQL 注入、XSS、CSRF、暴力破解,以及对应的防御手段。目标是用最小的成本把基础防护补齐。
SQL 注入
SQL 注入的原理在 Node.js MySQL 数据库 讲过,把用户输入拼进 SQL 字符串
// 危险写法,输入 "x' OR '1'='1" 直接绕过登录
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
输入变成 SQL 的一部分,条件被改写。防御只有一条,用参数化查询,输入永远当数据不当代码
await pool.execute(
"SELECT * FROM users WHERE email = ? AND password = ?",
[email, password]
);
Prisma 的查询方法内部也是参数化,所以用 ORM 的项目天然免疫这一类注入。
参数化能防注入,但不代表输入不用校验。长度、类型、枚举范围这些约束要在进入数据库前检查,用 zod 或者手写判断都行,校验不过直接返回 400,别让脏数据进 SQL。
XSS 跨站脚本
攻击者把脚本塞进你的页面,比如留言板存了 <script>偷cookie</script>,别人打开页面就中招。防御思路是输出转义,把 < 变成 <,脚本就执行不了
// 输出转义,把尖括号换成实体
const safe = String(content)
.replace(/</g, "<")
.replace(/>/g, ">");
响应头里可以用 CSP 声明哪些来源允许加载脚本
Content-Security-Policy: default-src 'self'; script-src 'self'
这句的意思是只从本站加载资源,helmet 默认会带上这条策略,后面有例子。纯 API 后端再加一条,页面渲染侧不要用 v-html 这类危险指令直接插入用户内容,前端模板默认的插值语法本身带转义,别轻易关掉。
CSRF 跨站请求伪造
如果登录凭证是 Cookie,攻击者可以在别的网站发一个请求,浏览器会自动带上你的 Cookie,等于替你操作。防御手段是在 Cookie 上加 SameSite 属性,浏览器就不会跨站携带
res.cookie("token", token, { sameSite: "lax" });
SameSite 有三个取值,strict 完全禁止跨站携带,lax 允许导航类请求携带,none 等于不设防,API 场景用 lax 比较均衡。而 JWT 放在请求头里,跨站请求根本带不上 token,天然规避 CSRF,这也是前后端分离项目偏爱 JWT 的原因之一。
速率限制
登录接口不设限,攻击者可以无限试密码,暴力破解只是时间问题。用 express-rate-limit 限制频率
npm install express-rate-limit
const rateLimit = require("express-rate-limit");
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 5, // 最多 5 次
message: { error: "尝试次数过多,请稍后再试" }
});
app.post("/api/login", loginLimiter, handler);
windowMs 是窗口时长,max 是窗口内允许的次数,超了直接返回 429。登录、注册、找回密码这类接口都要加。
整个应用还可以加一个宽松的全局限流,再在敏感接口上单独收紧
app.use(rateLimit({
windowMs: 60 * 60 * 1000, // 1 小时
max: 200, // 最多 200 次
message: { error: "请求过于频繁" }
}));
全局挡扫描器的乱枪打鸟,登录接口的 5 次限制专门防针对性爆破,两层不冲突。
helmet 安全响应头
helmet 是 Express 官方推荐的安全中间件,一行代码设置一批安全响应头
npm install helmet
const helmet = require("helmet");
app.use(helmet());
它默认启用 X-Content-Type-Options 防 MIME 嗅探、CSP 限制脚本来源、X-Frame-Options 防点击劫持等。开发阶段如果发现页面资源被 CSP 拦了,可以按需配置 contentSecurityPolicy 选项,别整个关掉。
要放行某个域名的 CDN 资源,在配置里按域名加
app.use(helmet({
contentSecurityPolicy: {
directives: {
"script-src": ["'self'", "cdn.example.com"]
}
}
}));
只加需要的域名,放行范围越小越安全。
HTTPS
生产环境必须启用 HTTPS,否则传输内容明文可见,登录密码、token 全裸奔。域名证书用 Let’s Encrypt 免费签发,配合 certbot 自动续期,常见的做法是在 Nginx 层终结 TLS
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Node 进程照常监听 3000 端口,证书配置和续期都只动 Nginx,应用代码不用改。部署相关的内容 Node.js 部署与日志 会展开讲。
实践
给 Node.js JWT 身份认证 那篇的应用加上 helmet 和登录限流。
// app.js
const express = require("express");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const app = express();
app.use(helmet());
app.use(express.json());
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: { error: "尝试次数过多,请稍后再试" }
});
app.post("/api/register", async (req, res) => {
const { name, email, password } = req.body;
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { name, email, passwordHash }
});
res.status(201).json({ id: user.id, name: user.name });
});
app.post("/api/login", loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: "邮箱或密码错误" });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return res.status(401).json({ error: "邮箱或密码错误" });
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: "7d"
});
res.json({ token });
});
app.listen(3000);
验证限流效果,连续输错密码 6 次,看每次的状态码
for i in 1 2 3 4 5 6; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/api/login \
-H "Content-Type: application/json" \
-d '{"email":"xm@example.com","password":"wrong"}'
done
前 5 次返回 401,第 6 次返回 429,说明限流生效。再用 curl -I 看响应头,能看到 helmet 加的 X-Content-Type-Options 等字段。
常见坑
- 只防了 SQL 注入就以为安全了,注入、XSS、CSRF、暴力破解是四个独立战场。
- 限流 max 配太大等于没配,登录接口 5 次封 15 分钟是比较通用的起点。
- helmet 拦了内网 CDN 或字体资源,报错第一反应去查 CSP,按域名放行而不是关闭。
- 生产环境用 HTTP 裸奔,密码和 token 全是明文传输。
- 把 JWT 放 Cookie 又不开 SameSite,等于把 CSRF 的洞又开了回来。
基础防护就位,下一篇把这些内容全部组装成一个完整项目,见 Node.js 博客 API 实战。