Node.js 应用在面向互联网时面临的安全威胁是多层次的:攻击者可能利用 XSS 窃取用户会话,通过 CSRF 构造恶意请求篡改数据,利用长时间有效的 JWT 实现横向权限提升,或者通过拒绝服务(DoS)致服务瘫痪。此外,简单的配置疏漏如缺少安全头部、过大的 JSON Body、未经验证的请求参数,都可能被自动化扫描工具利用,成为进入系统的缺口。一个生产级的部署方案需要从 HTTP 安全头部加固、请求体限制、CORS 跨域策略、认证授权机制、访问频率控制、容器化隔离、进程管理等多个维度构建防御纵深。本文围绕 Express 应用的生产化部署,逐层梳理每一道安全防线的配置细节与决策依据,从最外层的 HTTP 头部一直到最内层的密钥管理,形成完整的安全链路。
1. Helmet 安全头部
Helmet 通过设置一系列 HTTP 响应头部来减少常见的 Web 攻击面。虽然它不能阻止所有攻击,但默认配置已经屏蔽了绝大多数低成本的自动化扫描和中间人注入。
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"]
}
},
crossOriginEmbedderPolicy: false,
dnsPrefetchControl: { allow: false },
frameguard: { action: 'deny' },
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
referrerPolicy: { policy: 'strict-origin-when-cross-origin' }
}));
frameAncestors: ["'none'"] 等同于 X-Frame-Options: DENY,防止页面被嵌入到第三方 iframe 中实施点击劫持。CSP 中的 defaultSrc: ["'self'"] 是唯一信任源基准,后续指令如果未显式设置都会回退到 defaultSrc。hsts 头部的 preload 参数将域名提交到浏览器的 HSTS 预加载列表,使得首次访问(甚至从未访问过该站点的用户)也强制走 HTTPS,消除 HTTP 降级攻击的可能性。值得注意的是,crossOriginEmbedderPolicy 在某些需要加载第三方脚本(如 Google Analytics、Sentry 等)的场景下需要禁用,否则资源加载会因跨域隔离策略而失败。
1.1 严格的内容安全策略
对于不需要内联脚本的应用,CSP 可以配置得更严格:
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // 不允许 inline script
styleSrc: ["'self'", "'unsafe-inline'"], // style 通常需要 inline
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
}));
如果要完全消除 unsafe-inline,需要为每个内联脚本生成一个唯一的 nonce 属性,并在 CSP 头部中声明该 nonce。这通常需要模板引擎配合生成。
1.2 请求体限制与参数污染防护
const hpp = require('hpp');
const mongoSanitize = require('express-mongo-sanitize');
// 限制 JSON 和表单大小,防止内存耗尽
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
// HTTP 参数污染:阻止同名参数的数组注入
app.use(hpp());
// NoSQL 注入:转义 MongoDB 操作符
app.use(mongoSanitize());
express.json({ limit: '10kb' }) 的作用在表面上看起来是防止 DoS,但实际上更深层的价值是安全基线约束。许多漏洞源于服务端无条件信任客户端输入——超大 JSON 可能触发递归解析导致栈溢出,深层嵌套对象可能绕过对象结构校验。10KB 对于大多数 REST API 请求来说已经足够涵盖业务数据;如果确实需要上传大文件,应使用专用的文件上传接口配合流式处理,而不是通过 express.json() 接收。hpp 中间件处理 HTTP 参数污染:当请求中包含多个同名参数时(如 ?id=1&id=2),Express 默认会将它们解析为数组,这可能导致类型校验被绕过。mongoSanitize 将 $ne、$gt 等 MongoDB 操作符从请求体中移除,防止攻击者构造恶意查询。
2. CORS 白名单设计
CORS 是浏览器的安全机制,不是服务端的安全边界。Access-Control-Allow-Origin: * 配合 Access-Control-Allow-Credentials: true 的组合在浏览器层面是非法的,但许多开发者在错误配置下无意中创造了漏洞:当 credentials 为 true 时,origin 必须使用显式白名单,否则恶意站点可以通过修改 origin 头来读取跨域的敏感响应。
const cors = require('cors');
const whitelist = new Set([
'https://app.example.com',
'https://admin.example.com',
'http://localhost:3000'
]);
const corsOptions = {
origin: (origin, callback) => {
// origin 为 undefined 时,直接放行(服务器间通信或本地测试)
if (!origin || whitelist.has(origin)) {
callback(null, true);
} else {
callback(new Error(`CORS origin not allowed: ${origin}`));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Total-Count'],
maxAge: 86400 // 预检请求缓存 1 天
};
app.use(cors(corsOptions));
白名单应当从环境变量或集中配置服务加载,禁止硬编码。maxAge: 86400 让浏览器缓存预检(OPTIONS)请求结果,减少 OPTIONS 请求的往返开销。生产环境中,如果前端和后端部署在同一个域名下,优先使用反向代理(Nginx)统一域名,直接消除跨域问题,从根本上减少 CORS 的攻击面。
3. JWT 安全实现与 Token 轮换
JWT 的无状态特性使它在微服务架构中非常适用,但它也带来了不可撤销的问题。一旦签发,在过期时间到达之前服务端无法"注销"单个 Token。因此短有效期的 Access Token 加独立的 Refresh Token 成为一种兼顾安全性与用户体验的折中方案。
const jwt = require('jsonwebtoken');
const { promisify } = require('util');
const signAsync = promisify(jwt.sign);
const verifyAsync = promisify(jwt.verify);
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
async function generateTokenPair(userId, role) {
const accessToken = await signAsync(
{ sub: userId, role, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m', issuer: 'api.example.com', audience: 'web-client' }
);
const refreshToken = await signAsync(
{ sub: userId, type: 'refresh', jti: crypto.randomUUID() },
REFRESH_SECRET,
{ expiresIn: '7d', issuer: 'api.example.com' }
);
return { accessToken, refreshToken };
}
Access Token 限制在 15 分钟以内,即使被截获,攻击窗口也很有限。Refresh Token 使用独立的密钥签发,并携带唯一的 jti(JWT ID),服务端维护一个 Redis 集合存储所有有效的 jti。当检测到账号异常(异地登录、密码修改)时,从 Redis 中删除对应的 jti,下一次 Refresh 请求就会失败,从而强制用户重新登录。
async function authenticate(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing token' });
}
try {
const token = auth.substring(7);
const decoded = await verifyAsync(token, ACCESS_SECRET, {
issuer: 'api.example.com',
audience: 'web-client',
clockTolerance: 30 // 容忍最多 30 秒时钟偏差
});
// 检查 Token 是否已被显式撤销
const blacklisted = await redis.sismember('token:blacklist', decoded.jti);
if (blacklisted) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED'
});
}
res.status(401).json({ error: 'Invalid token' });
}
}
Refresh Token 的轮换策略:每次使用 Refresh Token 获取新的 Access Token 时,同时签发一个新的 Refresh Token 并将旧的加入黑名单。这样可以防止 Refresh Token 被重放攻击利用。
4. 限流策略
限流不仅是防止 DoS 的手段,也是保护业务逻辑(如短信验证码接口被滥发)的重要措施。不同的场景需要不同的限流算法。
4.1 滑动窗口限流
express-rate-limit 是最常用的限流中间件,基于固定窗口计数。虽然简单,但在窗口边界处可能出现两倍的请求通过量(上一窗口末尾的请求 + 本窗口开始的请求)。
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每 IP 100 请求
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
});
}
});
app.use('/api/', apiLimiter);
4.2 令牌桶限流
令牌桶算法的优势在于请求可以累积余额:如果某个时段流量很低,桶中令牌累积后可以在后续时段处理突发流量,上限由桶容量控制。rate-limiter-flexible 提供了基于 Redis 的分布式令牌桶实现,适合多实例部署场景。
const { RateLimiterRedis } = require('rate-limiter-flexible');
const Redis = require('ioredis');
const redisClient = new Redis({ host: 'localhost', port: 6379 });
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'api_limit',
points: 100, // 桶中最多 100 个令牌
duration: 60 // 每 60 秒补充 points 数量的令牌
});
async function rateLimitMiddleware(req, res, next) {
try {
await rateLimiter.consume(req.ip, 1);
next();
} catch (rejRes) {
res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: String(Math.round(rejRes.msBeforeNext / 1000))
});
}
}
4.3 独立限流点
登录、注册、密码重置等敏感接口的限流策略必须比其他接口更严格:
const strictLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 小时
max: 5, // 最多 5 次尝试
skipSuccessfulRequests: false,
keyGenerator: (req) => req.body.email || req.ip,
handler: (req, res) => {
res.status(429).json({
error: 'Too many login attempts',
retryAfter: Math.ceil(60 * 60) // 提示 1 小时后重试
});
}
});
app.use('/api/auth/login', strictLimiter);
app.use('/api/auth/forgot-password', strictLimiter);
使用 email 和 ip 组合作为限流键更为可靠:如果仅按 IP 限流,攻击者可以利用代理池绕过;如果仅按 email 限流,攻击者可以通过伪造不存在的 email 消耗服务器资源。实践中可以结合两者,先按 email 限流,再叠加一层 IP 级别的限流。
5. CSRF 防护
CSRF 攻击的本质是利用浏览器自动发送 Cookie 的特性,在用户不知情时向目标站点发起请求。前后端分离架构下,如果认证信息存储在 HttpOnly Cookie 中,则 CSRF 是真实存在的威胁。如果认证信息通过 Header 传递(如 Authorization: Bearer <token>),则 CSRF 在此场景下天然不存在,因为跨域请求无法构造自定义 Header。
对于需要 Cookie 认证的传统应用:
const csrf = require('csurf');
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: true,
sameSite: 'strict'
}
});
app.use(csrfProtection);
app.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken());
next();
});
app.get('/form', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
前端需要在每次请求中将 XSRF-TOKEN Cookie 的值复制到请求 Header 的 X-CSRF-Token 中。服务端验证两个值是否匹配。对于纯 API 服务(不依赖 Cookie 认证),更现实的做法是将 SameSite=Strict 或 SameSite=Lax 设为 Cookie 属性,并配合精确的 CORS 白名单。
6. PM2 集群模式
PM2 是 Node.js 生产环境最常用的进程管理器。单线程的 Node.js 事件循环无法在多核 CPU 上并行工作,PM2 的集群模式通过 child_process.fork() 创建多个工作进程,利用操作系统的负载均衡将请求分发到各进程。
// ecosystem.config.js
module.exports = {
apps: [{
name: 'api-server',
script: './dist/main.js',
instances: 'max', // 使用全部 CPU 核心
exec_mode: 'cluster', // 集群模式
max_memory_restart: '1G', // 内存超限自动重启
env: { NODE_ENV: 'production' },
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: './logs/err.log',
out_file: './logs/out.log',
merge_logs: true, // 多实例日志合并到同一文件
autorestart: true,
kill_timeout: 5000, // 等待 5 秒后强制终止
listen_timeout: 10000, // 等待应用 ready 信号的最长时间
wait_ready: true, // 等待 process.send('ready') 后才标记为在线
shutdown_with_message: true // 通过 IPC 发送 shutdown 消息
}]
};
# 启动集群
pm2 start ecosystem.config.js --env production
# 零停机重载(用新代码替换旧进程,旧进程处理完当前请求后退出)
pm2 reload api-server
# 动态扩缩容
pm2 scale api-server +2 # 增加 2 个工作进程
pm2 scale api-server 1 # 缩容到 1 个进程
# 实时监控
pm2 monit
wait_ready: true 要求应用在完成初始化后调用 process.send('ready'),在此之前 PM2 不会将该进程标记为在线,确保请求只发送到已准备就绪的进程。
7. Docker 多阶段构建
多阶段构建可以在同一个 Dockerfile 中定义多个构建阶段,最终只将运行阶段所需的文件打包到镜像中,显著减小最终镜像体积并避免构建依赖污染运行环境。
# 阶段一:依赖安装与构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# 编译 TypeScript(如有)
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# 阶段二:运行镜像
FROM node:20-alpine
WORKDIR /app
# 创建非 root 用户
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# 仅从 builder 阶段复制产物
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --chown=nodejs:nodejs package.json ./
# 切换到非 root 用户
USER nodejs
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); })"
CMD ["node", "dist/main.js"]
使用非 root 用户 (USER nodejs) 运行容器是安全基线。如果攻击者利用了应用漏洞获取了容器内的 shell,非 root 权限意味着无法修改系统文件、无法安装恶意软件,甚至无法读取其他用户的敏感挂载卷。HEALTHCHECK 让 Docker 和 Kubernetes 能够自动判断容器是否真正可用(而不仅仅是进程存在),在健康检查失败时自动重启容器。
8. 环境变量安全管理
dotenv 是开发环境中的常用工具,但在生产环境中将 .env 文件直接放入容器或服务器存在泄漏风险——文件可能被意外提交到版本控制、通过日志系统泄露、或被攻击者读取。
dotenvx 通过加密 .env 文件解决这一问题:
# 加密环境文件
npx dotenvx encrypt
# 加密后的 .env 文件可以安全提交到仓库
# 运行时指定解密密钥,不依赖文件存储
DOTENV_PRIVATE_KEY_PRODUCTION="xxx..." node dist/main.js
// 应用启动时自动解密
require('@dotenvx/dotenvx').config();
console.log(process.env.DATABASE_URL); // 自动解密
对于更高安全等级的场景,应当使用云厂商提供的密钥管理服务:AWS Secrets Manager、Azure Key Vault、GCP Secret Manager。应用在启动时通过 IAM 角色拉取密钥,密钥仅存在于内存中,从不进入文件系统。配合 external-secrets 等 Kubernetes operator,可以实现密钥的自动轮转和注入。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。