Node.js 安全实践与生产部署

Node.js 生产级安全与部署:输入校验、Helmet 安全头部、CORS 配置、Rate Limiting、JWT 安全、PM2 进程管理、Docker 容器化与 CI/CD 流水线。

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,可以实现密钥的自动轮转和注入。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js 优雅停机与健康检查实战
  2. Node.js 内存泄漏诊断实战
  3. BullMQ 后台任务队列实战