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 模板,新项目直接复制,从第一天起就建立安全基线。