16
安全与限流:helmet、JWT 校验、限流与凭据
Hardening a Node API: helmet, CORS, Rate Limiting & JWT
第 10 章有一份安全清单,这一章把它变成能直接抄进代码的配置。Node 服务天然暴露在公网,而且绝大多数事故不是因为"被高级攻击者盯上",而是因为默认配置没人改:响应头没加、接口没限流、JWT 校验只验了签名、错误信息把堆栈全吐给攻击者。这一章讲的每一项都是"十分钟就能做完、但不做可能赔上一整年"的事。
论加安全的正确顺序:先收窄面,再加锁
第一步是"减少暴露面":不需要的接口别开、错误信息别外泄、生产关掉调试端口、把进程放在 Nginx 后面。能被访问到的入口越少,需要防的地方就越少。
第二步才是"加锁":安全响应头(helmet)、跨域白名单(CORS)、限流、身份校验(JWT)、输入校验(zod)、密码哈希(argon2/bcrypt)。
第三步是"能看见":日志脱敏、审计记录、依赖漏洞扫描。安全事故里最可怕的不是被打了,而是被打了一个月才发现。
helmet:一行把安全响应头配齐
安全响应头是"给浏览器下的指令",告诉它怎么对待你返回的内容。手写十来个头容易漏,helmet 是 Express/Koa/Fastify 通用的一站式中间件,绝大多数项目用它的默认配置就已经比裸奔好一个数量级。
# 安装
pnpm add helmet
// app.js —— Express 5 + helmet 8
import express from "express";
import helmet from "helmet";
const app = express();
// ① 最重要的第一个中间件:越靠前越好
app.use(helmet({
// 内容安全策略:按自己页面的实际来源改,别照抄
contentSecurityPolicy: {
useDefaults: true,
directives: {
"default-src": ["'self'"],
"img-src": ["'self'", "data:", "https:"],
"connect-src": ["'self'", "https://api.example.com"],
"object-src": ["'none'"],
"frame-ancestors": ["'none'"], // 防点击劫持(替代 X-Frame-Options)
},
},
// HSTS:只在真正全站 HTTPS 后开,否则会把 http 用户锁在外面
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
// 需要被别的站点用 <img> 引用你返回的图片时,CORP 要放宽
crossOriginResourcePolicy: { policy: "cross-origin" },
// 要嵌入第三方(地图/支付)时,这两个可能要关掉
crossOriginEmbedderPolicy: false,
}));
// ② helmet 默认已移除 X-Powered-By,但显式关掉更清楚(少暴露一条技术栈线索)
app.disable("x-powered-by");
// ③ 请求体大小限制:不限制的话,一个 JSON 大包就能把内存打满
app.use(express.json({ limit: "100kb" }));
app.use(express.urlencoded({ extended: false, limit: "100kb" }));
// ④ 反向代理后面必须设 trust proxy,否则 req.ip 永远是 Nginx 的 IP,限流会误伤所有人
app.set("trust proxy", 1); // 只信任第一层代理;层数按你的架构填,别填 true
app.use(express.static("public", { dotfiles: "deny", index: false })); // 静态目录别暴露 .env 之类
helmet 默认帮你加的主要响应头(认识它们比背配置有用)
| 响应头 | 作用 |
Content-Security-Policy | 资源白名单,XSS 的兜底防线。 |
Strict-Transport-Security | HSTS,强制后续访问走 HTTPS,防降级劫持。 |
X-Content-Type-Options: nosniff | 禁止浏览器猜 MIME 类型,防止上传的文本被当脚本执行。 |
X-Frame-Options: SAMEORIGIN | 防点击劫持(被 iframe 套壳)。 |
Referrer-Policy | 控制跳转时带多少来源信息,避免 URL 里的敏感参数外泄。 |
Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy | 跨源隔离,防侧信道攻击与跨源资源盗用。 |
helmet 与 CORS 的三个"改了就好了"的坑
坑一:开了 CSP 之后接口的 JSON 也挂了?一般不会——CSP 影响的是浏览器加载/执行资源,纯 API 服务如果只返回 JSON、不返回 HTML,可以把 CSP 关掉(contentSecurityPolicy: false),只留其它头。但如果你的服务同时提供页面,就必须配好 CSP。
坑二:HSTS 开了之后本地 http 调试进不去。HSTS 会被浏览器记住(max-age 期间强制 https),误开到生产域名上会比较尴尬。开发环境别开 HSTS,先在小域名上验证。
坑三:接口报 CORS 错误就去加 *。Access-Control-Allow-Origin: * 与 credentials: true 浏览器不允许同时存在(会直接拒绝),而且 * 等于对全互联网开放。正确做法是回显白名单里的 Origin,见下一节。
CORS:不是"安全机制",是"放行规则"
先纠正一个常见误解:CORS 是浏览器的"同源策略"在挡你,不是服务端在挡你。服务端设置 CORS 头,意思是"我允许这个来源的页面读我的响应"。所以CORS 配得对不对,只影响浏览器里的前端能不能拿到数据,跟接口本身安不安全没有直接关系(Postman / curl 从来不受 CORS 限制)。
import cors from "cors";
const ALLOWED = [
"https://app.example.com",
"https://admin.example.com",
...(process.env.NODE_ENV !== "production" ? ["http://localhost:5173"] : []),
];
app.use(cors({
// ① 用函数回显 Origin:白名单命中才放行,不命中就返回 "false" 由浏览器拦截
origin(origin, callback) {
// 同源请求(或 curl/服务端调用)没有 Origin 头,直接放行
if (!origin || ALLOWED.includes(origin)) return callback(null, true);
callback(null, false);
},
credentials: true, // 允许携带 Cookie(配合前端 credentials: "include")
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
exposedHeaders: ["X-Total-Count"], // 分页总数这类头,前端默认读不到,要显式暴露
maxAge: 600, // 预检结果缓存 10 分钟,省掉大量 OPTIONS 请求
}));
// ② 顺手把预检请求统一处理掉,别让 OPTIONS 进业务逻辑
app.options("/*splat", cors());
// 注:Express 5 换了新的路径匹配语法,老的 app.options("*", cors()) 不再适用;
// 其实 app.use(cors(...)) 已经会处理 OPTIONS,这行只是显式兜底
限流:别让一个脚本把你打穿
限流(rate limiting)防的不是"高手攻击",而是三类极其常见的事故:① 恶意刷接口(撞库、爬数据、发短信);② 客户端 bug 造成的死循环请求;③ 某个接口慢导致重试风暴。没有限流的 API,等于把数据库的命门挂在公网上。
# 安装(多实例部署必须配共享 store)
pnpm add express-rate-limit rate-limit-redis
// middlewares/limiter.js
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import redis from "./redis.js"; // node-redis v4+ 客户端
// 公共 store:多进程 / 多副本必须用共享存储,否则每个进程各限各的,实际阈值翻 N 倍
const store = () => new RedisStore({
sendCommand: (...args) => redis.sendCommand(args),
prefix: "rl:",
});
// ① 全局兜底:每个"身份"每分钟 300 次
export const globalLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 300, // v7 起用 limit(老版本叫 max)
standardHeaders: "draft-7", // 返回 RateLimit 标准响应头
legacyHeaders: false, // 关掉过时的 X-RateLimit-* 头
store: store(),
// 已登录按用户 ID 限流,未登录按 IP;避免"共享出口 IP 的整栋楼被一起封"
keyGenerator: (req) => req.user?.id ?? req.ip,
handler: (req, res) => res.status(429).json({ error: "请求过于频繁,请稍后再试" }),
});
// ② 敏感接口单独收严:登录(防撞库)、发短信/邮件(防刷钱)
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
skipSuccessfulRequests: true, // 登录成功不计数,只盯失败尝试
// 同时按 IP + 账号限流:换 IP 撞同一个账号也挡得住
keyGenerator: (req) => `${req.ip}:${req.body?.email ?? ""}`,
store: store(),
});
// ③ 挂上去:全局在最前面,敏感的挂在具体路由上
app.use("/api", globalLimiter);
app.post("/api/login", loginLimiter, loginHandler);
app.post("/api/sms/send", rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, store: store() }), sendSms);
- 一定要设
trust proxy。反向代理后面如果没设,req.ip 全是 Nginx 的地址,等于把所有人算成一个人,正常用户会被误封(新版 express-rate-limit 会在启动时直接报错提醒你,别去关掉那个校验)。
- 限流要"分层":全局限一个宽阈值兜底,登录/发短信/导出这类高成本接口单独设很严的阈值,别指望一个阈值走天下。
- 返回 429 时要带
Retry-After(标准头里已经给了),并且前端要能识别它、给出"请稍后再试"的友好提示,而不是白屏报错。
- 限流不是鉴权。
limit: 10 只说明"每分钟最多试 10 次",撞库攻击用 10 万个 IP 分布式慢慢试依旧能过——真正的防线是强密码策略 + 二次验证 + 异常登录检测。
JWT 校验:签名验过了就够吗?不够
JWT 是"服务端签名、自己不带状态"的令牌。新手最常见的错误是:只调 jwt.verify(token, secret) 看一眼没抛错就当通过。这样会留下至少两类严重漏洞,下面五个校验点一个都不能省。
import jwt from "jsonwebtoken";
// 建议用非对称密钥(RS256/ES256):认证服务用私钥签,业务服务只用公钥验
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY;
export function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ error: "未提供访问令牌" });
}
try {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ["RS256"], // ★① 固定算法,绝不信任 token 头里的 alg
audience: "blog-api", // ★② 受众:这张令牌是发给谁的
issuer: "https://auth.example.com", // ★③ 签发者:谁发的
clockTolerance: 5, // ★④ 容忍 5 秒时钟偏差(多机部署必备)
maxAge: "2h", // ★⑤ 兜底有效期,防 exp 被绕过或缺失
});
// 拿到的 sub 是"用户标识",别用令牌里自带的 role 直接做高危授权判断
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
// 统一返回模糊信息:不要告诉攻击者"是过期了还是签名不对"
const expired = err.name === "TokenExpiredError";
return res.status(401).json({ error: expired ? "令牌已过期" : "令牌无效", code: expired ? "TOKEN_EXPIRED" : "TOKEN_INVALID" });
}
}
JWT 四个真出过事故的坑
坑一:不固定算法 → 算法混淆攻击。如果校验时不写 algorithms: ["RS256"],攻击者可以把头部的 alg 改成 none(不签名),或者改成 HS256 并用你的公钥当 HMAC 密钥签名(公钥通常是公开的)。库如果不拦住,验签就"通过"了。永远显式固定算法,这是最高优先级的一条。
坑二:拿 localStorage 存 access token。任何一处 XSS 都能把 token 读走并拿到站外使用。更稳的做法是令牌放 HttpOnly + Secure + SameSite Cookie;如果是跨域纯前后端分离必须用手 Authorization 头,那就必须把 XSS 防住(见前端册第 8 章)。
坑三:access token 有效期设成 7 天,还没有撤销机制。JWT 是无状态的,签发出去就收不回来——用户改密码、管理员封号,旧 token 依然有效。正确设计:access token 短(15 分钟~2 小时)+ refresh token 长但可撤销(存库、用一次换一次、检测到重复使用就吊销整条链)。
坑四:把敏感信息塞进 payload。JWT 的 payload 只是 Base64 编码,不是加密,谁都能解开看。别放手机号、身份证、内部角色配置这类信息。
凭据与输入:剩下几件必做的事
// ① 密码存储:argon2id(OWASP 当前推荐)或 bcrypt,永远不用 md5/sha256
import argon2 from "argon2";
const hash = await argon2.hash(password); // 默认 argon2id,带随机盐
const ok = await argon2.verify(hash, password);
// bcrypt 也行:bcrypt.hash(password, 12) —— 代价因子 10~12 之间,别再低
// ② 登录:统一错误信息 + 恒定耗时,防"账号枚举"
const user = await db.findUserByEmail(email);
if (!user) {
// 用户不存在也要跑一次假校验,否则响应快慢会暴露"这个邮箱注册过"
await argon2.verify(DUMMY_HASH, password);
return res.status(401).json({ error: "邮箱或密码错误" });
}
if (!(await argon2.verify(user.passwordHash, password))) {
return res.status(401).json({ error: "邮箱或密码错误" }); // 同样的文案
}
// ③ 日志脱敏:别把密码和令牌写进日志(日志往往比数据库更容易被看到)
import pino from "pino";
export const logger = pino({
redact: {
paths: ["req.headers.authorization", "req.headers.cookie", "*.password", "*.token", "*.secret"],
censor: "[已脱敏]",
},
});
// ④ 错误处理:生产环境绝不把堆栈返回给客户端
app.use((err, req, res, next) => {
logger.error({ err, url: req.url }, "请求处理失败"); // 细节写进日志
const status = err.status ?? 500;
res.status(status).json({
error: status >= 500 ? "服务器内部错误" : err.message, // 5xx 只给模糊信息
...(process.env.NODE_ENV !== "production" ? { stack: err.stack } : {}),
});
// 注意:这个中间件必须是 4 个参数,且挂在所有路由之后
});
上线前的安全自查表(照着勾)
| 项目 | 确认内容 |
| 响应头 | helmet 已挂(CSP / HSTS / nosniff / frame-ancestors),x-powered-by 已移除。 |
| 跨域 | CORS 用白名单回显 Origin,没有出现 * 配 credentials: true。 |
| 限流 | 全局限流已挂,登录 / 短信 / 导出等高成本接口单独收严;多副本用了 Redis 共享 store。 |
| 代理与 IP | trust proxy 按真实层数设置,限流与审计拿到的 req.ip 是真实客户端 IP。 |
| 认证 | JWT 校验固定算法 + 校验 aud/iss + maxAge;access token 短有效期 + refresh 可撤销。 |
| 密码 | argon2id / bcrypt(≥10) 存储;登录错误信息统一;主要接口已接二次验证(高危操作建议必开)。 |
| 输入 | 请求体有大小限制(如 100kb);所有外部输入过 zod/class-validator;参数化 SQL;上传文件校验类型与大小。 |
| 错误与日志 | 5xx 不返回堆栈;日志已脱敏(authorization / cookie / password);有审计日志记录敏感操作。 |
| 超时 | 给 HTTP server 设 requestTimeout / headersTimeout,给所有外部调用设超时,防慢速攻击与挂死。 |
| 依赖与密钥 | CI 里跑 pnpm audit(设严重级别门禁)、开 Dependabot;密钥走环境变量/密钥管理服务,不写在代码里、不进 git。 |
记
本章小结
① 顺序:先减少暴露面,再加锁,最后让它"可观测"。别一上来就堆安全组件。
② helmet 一行配齐安全响应头(CSP / HSTS / nosniff / frame-ancestors);HSTS 只在全站 HTTPS 后开。
③ CORS 是放行规则不是安全机制:用白名单回显 Origin,绝不用 * 配 credentials。
④ 限流要分层 + 共享 store;trust proxy 不设,限流会误封所有人。
⑤ JWT 五个校验点:固定 algorithms、audience、issuer、clockTolerance、maxAge。把算法钉死是最高优先级。
⑥ 凭据与输入:argon2id / bcrypt 存密码、登录信息统一防枚举、请求体限量、错误不回堆栈、日志脱敏、依赖审计。
本章自测(Node 安全加固)
1.(安全题)下面这段 JWT 校验代码有什么漏洞?攻击者怎么利用?
const payload = jwt.verify(token, PUBLIC_KEY);
查看答案
答案:没固定算法,存在"算法混淆"风险。库会根据 token 头部声明的 alg 来选择验签算法。攻击者可以:① 把 alg 改成 none 提交不签名(或空签名的)令牌;② 把 alg 改成 HS256,然后用你公开的 RSA 公钥当 HMAC 密钥去签名——如果服务端照着头部的 alg 用 HS256 验,就会"验签通过"。修法:jwt.verify(token, PUBLIC_KEY, { algorithms: ["RS256"], audience, issuer, maxAge }),把算法、受众、签发者、有效期全部钉死。
2.(排错题)线上接口上线后大量正常用户收到 429,而实际 QPS 并不高。最可能的原因是什么?
查看答案
答案:trust proxy 没配(或配错)。服务跑在 Nginx / 负载均衡后面时,如果没有 app.set("trust proxy", 1),Express 认为的 req.ip 是代理的地址——所有用户共享同一个 key,于是全局限流被"一个人"用光。新版 express-rate-limit 启动时会直接报错提示这个配置问题,不要为了让它闭嘴去把校验关掉。另一个可能:多副本部署但用了内存 store,各进程独立计数导致行为漂移,应换 Redis 共享 store。
3.(设计题)为什么要"短的 access token + 长的 refresh token"?refresh token 为什么要"用一次换一次"?
查看答案
答案:JWT 是无状态的,签出去就收不回来,所以 access token 必须短(15 分钟~2 小时),把泄漏窗口压小。但短有效期会让用户频繁掉线,于是用有效期长的 refresh token 来换新的 access token,而 refresh token 存在服务端数据库里,可以撤销(改密码、登出、封号时删掉)。"用一次换一次"(rotation)是为了让盗用可被发现:每次刷新都签发新的 refresh token 并作废旧的;如果攻击者用了被作废的旧 token,说明它被复制过,服务端立即吊销该用户整条 refresh 链,强制重新登录。
4.(对比题)接口限流设成"每 IP 每分钟 100 次",能挡住撞库攻击吗?为什么?该怎么加固?
查看答案
答案:不能。撞库攻击者可以用大量代理 IP 分布式慢慢试,每个 IP 都不超阈值,但总尝试量巨大;反过来,如果很多正常用户在公司/学校共享出口 IP,还会被误伤。加固思路:① 登录接口按 IP + 账号双维度限流(keyGenerator 返回 ip:email),换个 IP 撞同一个账号照样被挡;② skipSuccessfulRequests: true,只统计失败;③ 失败次数达到阈值触发验证码 / 二次验证 / 临时锁定;④ 监控异常登录(地理跳变、UA 突变),必要时要求人工验证。限流只是缓解手段,不是撞库的主防线。
5.(实践题)为什么登录失败时,"用户不存在"和"密码错误"必须返回同样的错误信息,而且要尽量耗时一致?
查看答案
答案:为了防账号枚举。如果两种情况的提示不同(或响应耗时有明显差异——数据库查不到用户时立刻返回,查到用户时还要跑一次昂贵的密码哈希),攻击者就能用脚本批量探测"哪些邮箱在你的系统里注册过",拿到有效账号列表后再针对性地撞库或钓鱼。做法是:统一返回"邮箱或密码错误";用户不存在时也执行一次假校验(argon2.verify(DUMMY_HASH, password))把耗时拉平;注册、找回密码等接口也一样处理。