六维教程

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>,别人打开页面就中招。防御思路是输出转义,把 < 变成 &lt;,脚本就执行不了

// 输出转义,把尖括号换成实体
const safe = String(content)
  .replace(/</g, "&lt;")
  .replace(/>/g, "&gt;");

响应头里可以用 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 实战

上一篇
Node.js JWT 身份认证
下一篇
Node.js 博客 API 实战