Express / Node.js 安全最佳实践

1. Node.js 特有攻击面

类别 具体风险 影响
依赖供应链 npm 包投毒、typosquatting 代码执行
原型污染 obj["__proto__"].polluted=true 权限提升、DoS
NoSQL 注入 MongoDB \$gt\$ne\$regex 操作符 绕过认证、数据泄露
JWT 漏洞 密钥硬编码、none 算法、弱 secret 会话伪造
eval / Function 动态执行任意 JS RCE
child_process exec 命令注入 RCE
路径遍历 ../../etc/passwd 拼接 任意文件读
CORS 配置 origin: "*" + credentials CSRF + Token 泄露

2. Express 项目完整加固模板

2.1 package.json 安全依赖

{
  "dependencies": {
    "express": "^4.19.0",
    "helmet": "^7.1.0",
    "cors": "^2.8.5",
    "express-rate-limit": "^7.2.0",
    "express-validator": "^7.0.1",
    "csurf": "^1.11.0",
    "dotenv": "^16.4.0",
    "jsonwebtoken": "^9.0.2",
    "bcryptjs": "^2.4.3",
    "mongoose": "^8.2.0"
  },
  "devDependencies": {
    "eslint": "^8.57.0",
    "eslint-plugin-security": "^2.1.1"
  },
  "scripts": {
    "audit": "npm audit --high",
    "outdated": "npm outdated"
  }
}

2.2 核心安全中间件

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
const app = express();

// Helmet:HTTP 头安全
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"],
      frameAncestors: ["'none'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  frameguard: { action: 'deny' },
  xssFilter: true,
  noSniff: true,
}));

// CORS:严格白名单
app.use(cors({
  origin: ['https://app.example.com'],
  credentials: true,
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type', 'Authorization'],
}));

// 速率限制
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests' },
});
app.use('/api/', limiter);

// CSRF(cookie-based session 时开启)
app.use(cookieParser());
app.use(csrf({ cookie: { httpOnly: true, secure: true, sameSite: 'strict' } }));

// Body 解析限制
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));

// 请求日志(生产用 pino)
app.use((req, res, next) => {
  console.log(new Date().toISOString() + ' ' + req.method + ' ' + req.url + ' ' + req.ip);
  next();
});

module.exports = app;

2.3 错误处理中间件(必须最后)

// 404
app.use((req, res) => {
  res.status(404).json({ error: 'Not Found' });
});

// 全局错误
app.use((err, req, res, next) => {
  console.error('[ERROR]', err);
  const isProd = process.env.NODE_ENV === 'production';
  res.status(err.status || 500).json({
    error: isProd ? 'Internal Server Error' : err.message,
  });
});

3. 原型污染 PoC 与防御

3.1 什么是原型污染

// 攻击:篡改 Object.prototype
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
const obj = {};
Object.assign(obj, userInput);
console.log({}.isAdmin); // true —— 所有对象都被污染了!
console.log(user.isAdmin); // 如果 user 对象被用来做权限判断则被绕过

3.2 影响场景

  • 权限判断:if (user.isAdmin) 被绕过;
  • 配置合并:merge(userConfig, input) 可以篡改 settings;
  • 模板引擎注入:pug/ejs 通过污染 __proto__ 绕过沙箱。

3.3 防御

// 方案 1:递归过滤 __proto__ / constructor / prototype
function sanitize(obj) {
  if (typeof obj !== 'object' || obj === null) return obj;
  for (const key of Object.keys(obj)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      delete obj[key];
    } else {
      sanitize(obj[key]);
    }
  }
  return obj;
}

// 方案 2:使用 Map 或 Object.create(null)
const safeObj = Object.create(null);

// 方案 3:使用安全的 merge
const merge = require('lodash.mergewith');
const sanitized = JSON.parse(JSON.stringify(userInput));

// eslint 禁止 dangerous assignments
// eslint-disable-next-line security/detect-object-injection

4. NoSQL 注入 PoC 与防御

4.1 MongoDB 注入

// 漏洞代码
router.post('/login', async (req, res) => {
  const user = await User.findOne({ username: req.body.username, password: req.body.password });
  if (user) res.json({ token: jwt.sign({ id: user._id }, SECRET) });
});

// PoC:密码字段传入 $ne 操作符
POST /login
Content-Type: application/json

{"username": "admin", "password": {"$ne": ""}}
// 成功登录 admin 并拿到 JWT!

4.2 防御

const { body, validationResult } = require('express-validator');

// 方案 1:输入类型严格校验
router.post('/login',
  body('username').isString().isLength({ min: 1, max: 50 }).trim(),
  body('password').isString().isLength({ min: 8, max: 100 }),
  async (req, res) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
    const { username, password } = req.body;
    const user = await User.findOne({ username });
    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    res.json({ token: jwt.sign({ id: user._id }, process.env.JWT_SECRET) });
  }
);

// 方案 2:使用 mongoose 的 lean + schema 严格模式
// 方案 3:用 express-mongo-sanitize
const mongoSanitize = require('express-mongo-sanitize');
app.use(mongoSanitize());

5. JWT 安全清单

const jwt = require('jsonwebtoken');

// 生成
const token = jwt.sign(
  { id: user._id, role: user.role },
  process.env.JWT_SECRET,
  { algorithm: 'HS256', expiresIn: '2h', issuer: 'api.example.com' }
);

// 验证(必须指定 algorithms 防止 none 攻击)
jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  issuer: 'api.example.com',
  maxAge: '2h',
});

// 环境变量
// .env
JWT_SECRET=your-super-long-random-string-at-least-32-bytes
JWT_ISSUER=api.example.com
NODE_ENV=production

6. Path Traversal PoC 与防御

// 漏洞:静态文件服务
app.get('/files/:path', (req, res) => {
  res.sendFile(path.join(__dirname, 'uploads', req.params.path));
});
// PoC: GET /files/../../../etc/passwd

// 防御:规范化后检查前缀
const fs = require('fs');
app.get('/files/:path', (req, res) => {
  const base = path.resolve(__dirname, 'uploads');
  const target = path.resolve(base, req.params.path);
  if (!target.startsWith(base + path.sep)) {
    return res.status(400).json({ error: 'Invalid path' });
  }
  if (!fs.existsSync(target)) return res.status(404).send('Not Found');
  res.sendFile(target);
});

7. child_process 命令注入

// 漏洞
exec('convert ' + req.query.file + ' thumb.jpg', (err, out) => ...);

// 安全:用 execFile / spawn
execFile('convert', [req.query.file, 'thumb.jpg'], (err, out) => ...);
// 或直接拒绝用户输入作为第一个参数

8. npm 供应链防护

# 强制 npm audit
npm audit --audit-level=high

# 使用 npm-audit-resolver 自动处理
npm install -g npm-audit-resolver

# 锁定依赖 hash (package-lock.json 必须进 git)
git add package-lock.json

# 引入 Snyk / Socket.dev 做持续监控
npx snyk test
npx socket audit

# ESLint 安全插件
npm install --save-dev eslint-plugin-security

9. OWASP Top 10 对应

OWASP 2021 Node.js / Express 对应
A01 Broken Access Control JWT 密钥硬编码、原型污染绕过 isAdmin、原型污染覆盖配置
A02 Cryptographic Failures JWT none 算法、bcrypt salt 过小、HTTPS 未启用
A03 Injection NoSQL 注入、child_process exec 命令注入、eval
A04 Insecure Design 没有 rate limit、没有 CORS 白名单、没有 csrf
A05 Security Misconfiguration helmet 缺失、CSP 未配置、错误页泄露
A06 Vulnerable & Outdated Components 不运行 npm audit、lockfile 不提交
A07 Identification & Authentication Failures bcrypt 用了 salt 1、弱口令无 lockout
A08 Software & Data Integrity Failures 没有 npm audit 钩子、CI/CD 无 artifact 签名
A09 Security Logging & Monitoring Failures 没有 pino / winston 结构化日志
A10 SSRF 用户可控 URL 直接传给 fetch / request

10. 总结

Node.js 项目安全 = helmet(HTTP 头) + cors 白名单 + rate-limit + express-validator + mongo-sanitize + csrf + npm audit + 禁止 eval/exec + 严格 JWT

建议把上述 2.2 节代码做成 express-starter 模板,新项目直接复制,从第一天起就建立安全基线。