Node.js 安全最佳实践
构建安全的 Node.js 应用需要在开发、部署、运维的每个环节都遵循安全规范。本文档整理了 Node.js 应用的安全编码规范和最佳实践。
安全架构概览
安全防护分层架构
code
┌─────────────────────────────────────────────────────────────────────┐
│ 应用安全层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 输入验证 │ │ 输出编码 │ │ 会话管理 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 认证授权 │ │ 访问控制 │ │ 速率限制 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 传输安全层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ HTTPS/TLS │ │ 安全头部 │ │ CORS 策略 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 数据安全层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 数据加密 │ │ 密钥管理 │ │ 数据脱敏 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SQL 注入防护│ │ NoSQL 注入 │ │ 备份恢复 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 基础设施安全层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 容器安全 │ │ 网络隔离 │ │ 依赖安全 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 日志审计 │ │ 监控告警 │ │ 漏洞扫描 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘安全开发流程
code
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ 设计阶段 │───▶│ 编码阶段 │───▶│ 测试阶段 │───▶│ 部署阶段 │
└────────────┘ └────────────┘ └────────────┘ └────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ 威胁建模 │ │ 安全编码 │ │ 安全测试 │ │ 安全配置 │
│ 架构评审 │ │ 代码审查 │ │ 渗透测试 │ │ 漏洞扫描 │
│ 安全需求 │ │ 静态分析 │ │ 依赖审计 │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘ └────────────┘输入验证与输出编码
输入验证原则
永远不要信任任何来自用户的输入,包括:
- URL 参数
- 表单数据
- HTTP 头
- Cookie
- 文件上传
- API 请求体
- WebSocket 消息
输入验证最佳实践
javascript
const Joi = require('joi')
const { body, param, query, validationResult } = require('express-validator')
// ==================== Joi 验证模式 ====================
const schemas = {
// 用户注册验证
register: Joi.object({
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required()
.messages({
'string.alphanum': '用户名只能包含字母和数字',
'string.min': '用户名至少需要 {#limit} 个字符',
'string.max': '用户名不能超过 {#limit} 个字符'
}),
email: Joi.string()
.email()
.required()
.messages({
'string.email': '请输入有效的邮箱地址'
}),
password: Joi.string()
.min(8)
.max(128)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/)
.required()
.messages({
'string.pattern.base': '密码必须包含大小写字母、数字和特殊字符',
'string.min': '密码至少需要 {#limit} 个字符'
}),
age: Joi.number()
.integer()
.min(18)
.max(120)
.optional(),
phone: Joi.string()
.pattern(/^1[3-9]\d{9}$/)
.optional()
.messages({
'string.pattern.base': '请输入有效的手机号码'
})
}),
// 查询参数验证
query: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(10),
sort: Joi.string().valid('asc', 'desc').default('desc'),
search: Joi.string().max(100).optional()
}),
// MongoDB ObjectId 验证
objectId: Joi.string()
.pattern(/^[0-9a-fA-F]{24}$/)
.message('无效的 ID 格式')
}
// ==================== 验证中间件 ====================
function validate(schema, source = 'body') {
return (req, res, next) => {
const data = source === 'query' ? req.query :
source === 'params' ? req.params : req.body
const { error, value } = schema.validate(data, {
abortEarly: false, // 返回所有错误
stripUnknown: true, // 删除未知字段
convert: true // 自动类型转换
})
if (error) {
return res.status(400).json({
error: '输入验证失败',
code: 'VALIDATION_ERROR',
details: error.details.map(d => ({
field: d.path.join('.'),
message: d.message,
type: d.type
}))
})
}
// 使用验证后的数据替换原始数据
req[source] = value
next()
}
}
// ==================== 使用示例 ====================
// 用户注册
app.post('/register',
validate(schemas.register, 'body'),
async (req, res) => {
// req.body 已经过验证和清理
const user = await createUser(req.body)
res.json({ success: true, user })
}
)
// 分页查询
app.get('/users',
validate(schemas.query, 'query'),
async (req, res) => {
const { page, limit, sort, search } = req.query
const users = await getUsers({ page, limit, sort, search })
res.json(users)
}
)
// 路径参数验证
app.get('/users/:id',
validate(Joi.object({ id: schemas.objectId }), 'params'),
async (req, res) => {
const user = await User.findById(req.params.id)
res.json(user)
}
)express-validator 示例
javascript
const { body, param, query, validationResult } = require('express-validator')
// 验证结果处理
const validate = (req, res, next) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
error: '输入验证失败',
details: errors.array().map(err => ({
field: err.path,
message: err.msg,
value: err.value
}))
})
}
next()
}
// 用户注册验证规则
const registerRules = [
body('username')
.trim()
.isLength({ min: 3, max: 30 })
.withMessage('用户名长度必须在 3-30 个字符之间')
.isAlphanumeric()
.withMessage('用户名只能包含字母和数字'),
body('email')
.trim()
.normalizeEmail()
.isEmail()
.withMessage('请输入有效的邮箱地址'),
body('password')
.isLength({ min: 8, max: 128 })
.withMessage('密码长度必须在 8-128 个字符之间')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('密码必须包含大小写字母和数字'),
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.password) {
throw new Error('两次输入的密码不一致')
}
return true
}),
validate
]
app.post('/register', registerRules, registerHandler)输出编码
防止 XSS 攻击,对输出进行编码:
javascript
const escapeHtml = require('escape-html')
const validator = require('validator')
// ==================== HTML 编码 ====================
app.get('/search', (req, res) => {
const keyword = req.query.q
const safeKeyword = escapeHtml(keyword)
res.send(`<p>搜索结果: ${safeKeyword}</p>`)
})
// ==================== URL 编码 ====================
app.get('/redirect', (req, res) => {
const url = req.query.url
// 验证 URL 格式
if (!validator.isURL(url, {
require_protocol: true,
protocols: ['http', 'https']
})) {
return res.status(400).json({ error: '无效的 URL' })
}
res.redirect(encodeURIComponent(url))
})
// ==================== JavaScript 编码 ====================
function escapeJs(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/'/g, "\\'")
.replace(/</g, '\\x3c')
.replace(/>/g, '\\x3e')
.replace(/&/g, '\\x26')
.replace(/=/g, '\\x3d')
}
// ==================== JSON 安全响应 ====================
app.get('/api/users', (req, res) => {
const users = getUsers()
// Express 自动进行 JSON 编码,安全
res.json(users)
// 如果手动拼接 JSON,需要转义
// const safe = JSON.stringify(users)
// res.send(safe)
})
// ==================== 模板引擎安全 ====================
// EJS 模板
// <%= userInput %> // 自动转义(安全)
// <%- userInput %> // 不转义(危险!)
// 安全使用不转义输出
app.get('/profile', (req, res) => {
const user = getUser(req.session.userId)
res.render('profile', {
user,
// 预先转义的内容可以安全使用 <%- %>
safeContent: escapeHtml(user.bio)
})
})
// ==================== 富文本内容处理 ====================
const sanitizeHtml = require('sanitize-html')
function sanitizeRichText(html) {
return sanitizeHtml(html, {
allowedTags: ['p', 'b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li'],
allowedAttributes: {
'a': ['href', 'title']
},
allowedSchemes: ['http', 'https', 'mailto']
})
}
app.post('/comment', (req, res) => {
const content = sanitizeRichText(req.body.content)
createComment({ content, userId: req.session.userId })
res.json({ success: true })
})认证授权最佳实践
密码安全
javascript
const bcrypt = require('bcrypt')
const crypto = require('crypto')
// ==================== 密码哈希 ====================
// 推荐配置
const SALT_ROUNDS = 12 // 10-12 是推荐值,越大越安全但越慢
async function hashPassword(password) {
// 验证密码强度
if (!isStrongPassword(password)) {
throw new Error('密码强度不足')
}
return bcrypt.hash(password, SALT_ROUNDS)
}
async function verifyPassword(password, hash) {
// 使用时间恒定比较
return bcrypt.compare(password, hash)
}
// 密码强度验证
function isStrongPassword(password) {
const minLength = 8
const hasLower = /[a-z]/.test(password)
const hasUpper = /[A-Z]/.test(password)
const hasNumber = /\d/.test(password)
const hasSpecial = /[@$!%*?&]/.test(password)
return password.length >= minLength &&
hasLower && hasUpper && hasNumber && hasSpecial
}
// ==================== 用户注册 ====================
app.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body
// 检查用户是否存在
const existing = await User.findOne({ $or: [{ email }, { username }] })
if (existing) {
return res.status(409).json({ error: '用户已存在' })
}
// 哈希密码
const hashedPassword = await hashPassword(password)
// 创建用户
const user = await User.create({
username,
email,
password: hashedPassword,
emailVerified: false
})
// 发送验证邮件
await sendVerificationEmail(user)
res.status(201).json({
success: true,
message: '注册成功,请验证邮箱'
})
} catch (error) {
logger.error('注册失败', error)
res.status(500).json({ error: '注册失败' })
}
})
// ==================== 登录流程 ====================
app.post('/login', loginLimiter, async (req, res) => {
const { email, password } = req.body
const user = await User.findOne({ email })
// 即使用户不存在也执行哈希比较(防止时序攻击)
const hashedPassword = user?.password || '$2b$12$dummyhashforfakeuser'
const isValid = await verifyPassword(password, hashedPassword)
if (!user || !isValid) {
// 记录失败尝试
await logFailedLogin(email, req.ip)
return res.status(401).json({ error: '邮箱或密码错误' })
}
// 检查账户状态
if (user.lockedUntil && user.lockedUntil > new Date()) {
return res.status(403).json({
error: '账户已锁定',
lockedUntil: user.lockedUntil
})
}
// 登录成功,重置失败计数
await User.updateOne(
{ _id: user._id },
{
$set: {
lastLogin: new Date(),
loginIp: req.ip
},
$unset: { lockedUntil: 1, failedAttempts: 1 }
}
)
// 生成令牌
const token = generateAuthToken(user)
// 记录登录
logger.info('用户登录', { userId: user._id, ip: req.ip })
res.json({
success: true,
token,
user: { id: user._id, username: user.username, email: user.email }
})
})JWT 安全配置
javascript
const jwt = require('jsonwebtoken')
// ==================== JWT 配置 ====================
const JWT_CONFIG = {
accessToken: {
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: '15m' // 短期令牌
},
refreshToken: {
secret: process.env.JWT_REFRESH_SECRET,
expiresIn: '7d' // 长期刷新令牌
}
}
// ==================== 令牌生成 ====================
function generateTokens(user) {
const payload = {
userId: user._id,
role: user.role,
// 不要存储敏感信息
}
const accessToken = jwt.sign(payload, JWT_CONFIG.accessToken.secret, {
expiresIn: JWT_CONFIG.accessToken.expiresIn,
issuer: 'your-app',
audience: 'your-app-users'
})
const refreshToken = jwt.sign(
{ userId: user._id, tokenId: crypto.randomUUID() },
JWT_CONFIG.refreshToken.secret,
{ expiresIn: JWT_CONFIG.refreshToken.expiresIn }
)
return { accessToken, refreshToken }
}
// ==================== 令牌验证中间件 ====================
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: '未提供认证令牌' })
}
const token = authHeader.split(' ')[1]
try {
const decoded = jwt.verify(token, JWT_CONFIG.accessToken.secret, {
issuer: 'your-app',
audience: 'your-app-users'
})
req.user = decoded
next()
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: '令牌已过期',
code: 'TOKEN_EXPIRED'
})
}
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
error: '无效的令牌',
code: 'TOKEN_INVALID'
})
}
return res.status(401).json({ error: '认证失败' })
}
}
// ==================== 权限检查 ====================
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '未认证' })
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '权限不足' })
}
next()
}
}
// 使用示例
app.delete('/users/:id',
authMiddleware,
requireRole('admin'),
deleteUser
)权限控制
javascript
// ==================== RBAC 权限系统 ====================
const permissions = {
admin: ['read', 'write', 'delete', 'manage_users'],
editor: ['read', 'write'],
viewer: ['read']
}
function hasPermission(user, action) {
const userPermissions = permissions[user.role] || []
return userPermissions.includes(action)
}
// 权限中间件
function requirePermission(action) {
return (req, res, next) => {
if (!hasPermission(req.user, action)) {
return res.status(403).json({
error: '权限不足',
required: action
})
}
next()
}
}
// ==================== 资源级权限 ====================
// 检查用户是否有权访问特定资源
async function canAccessResource(userId, resourceId, action) {
const resource = await Resource.findById(resourceId)
// 资源所有者有完全访问权
if (resource.ownerId.toString() === userId) {
return true
}
// 检查共享权限
const permission = resource.sharedWith.find(
share => share.userId.toString() === userId
)
return permission?.actions.includes(action)
}
// 资源权限中间件
async function checkResourceAccess(req, res, next) {
const { resourceId } = req.params
const action = req.method === 'GET' ? 'read' :
req.method === 'DELETE' ? 'delete' : 'write'
const hasAccess = await canAccessResource(
req.user.userId,
resourceId,
action
)
if (!hasAccess) {
return res.status(403).json({ error: '无权访问此资源' })
}
next()
}安全 HTTP 头配置
使用 Helmet 中间件
javascript
const helmet = require('helmet')
// ==================== 基本配置 ====================
app.use(helmet())
// ==================== 详细配置 ====================
app.use(helmet({
// 内容安全策略
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
"'unsafe-inline'", // 尽量避免
"https://cdn.jsdelivr.net"
],
styleSrc: [
"'self'",
"'unsafe-inline'",
"https://fonts.googleapis.com"
],
fontSrc: [
"'self'",
"https://fonts.gstatic.com"
],
imgSrc: [
"'self'",
"data:",
"https:",
"blob:"
],
connectSrc: [
"'self'",
"https://api.example.com"
],
frameSrc: ["'none'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
upgradeInsecureRequests: []
},
reportOnly: false // 设为 true 可先观察不阻止
},
// XSS 过滤器(现代浏览器已弃用,但有兼容性)
xssFilter: true,
// 禁止点击劫持
frameguard: {
action: 'deny' // 或 'sameorigin'
},
// 隐藏 X-Powered-By
hidePoweredBy: true,
// 防止 MIME 类型嗅探
noSniff: true,
// HSTS(HTTP 严格传输安全)
hsts: {
maxAge: 31536000, // 1 年
includeSubDomains: true,
preload: true
},
// 禁用 DNS 预解析
dnsPrefetchControl: {
allow: false
},
// 引用策略
referrerPolicy: {
policy: 'strict-origin-when-cross-origin'
},
// 权限策略
permittedCrossDomainPolicies: {
permittedPolicies: 'none'
},
// IE 特定
ieNoOpen: true,
// Origin 隔离
originAgentCluster: true
}))
// ==================== CSP 报告端点 ====================
app.post('/csp-report',
express.json({ type: 'application/csp-report' }),
(req, res) => {
logger.warn('CSP 违规', req.body)
res.status(204).end()
}
)HTTP 安全头详解
| 头部名称 | 作用 | 推荐值 |
|---|---|---|
| Content-Security-Policy | 控制资源加载来源 | 根据业务配置白名单 |
| X-Frame-Options | 防止点击劫持 | DENY 或 SAMEORIGIN |
| X-Content-Type-Options | 防止 MIME 嗅探 | nosniff |
| X-XSS-Protection | XSS 过滤器 | 1; mode=block |
| Strict-Transport-Security | 强制 HTTPS | max-age=31536000; includeSubDomains |
| Referrer-Policy | 控制引用信息 | strict-origin-when-cross-origin |
| Permissions-Policy | 浏览器功能限制 | 根据需要禁用 |
| X-Permitted-Cross-Domain-Policies | 跨域策略 | none |
javascript
// ==================== 手动设置安全头 ====================
app.use((req, res, next) => {
// 移除敏感头
res.removeHeader('X-Powered-By')
res.removeHeader('Server')
// 设置安全头
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('X-XSS-Protection', '1; mode=block')
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
// 权限策略
res.setHeader('Permissions-Policy', [
'geolocation=()',
'microphone=()',
'camera=()',
'payment=()',
'usb=()'
].join(', '))
// Cache-Control(敏感页面)
if (req.path.startsWith('/api/') || req.path.startsWith('/account/')) {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
res.setHeader('Pragma', 'no-cache')
res.setHeader('Expires', '0')
}
next()
})数据安全与加密
敏感数据加密
javascript
const crypto = require('crypto')
// ==================== 对称加密(AES-256-GCM)====================
const ENCRYPTION_KEY = Buffer.from(process.env.ENCRYPTION_KEY, 'hex') // 32字节
const ALGORITHM = 'aes-256-gcm'
function encrypt(text) {
const iv = crypto.randomBytes(16) // 初始化向量
const cipher = crypto.createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag() // 认证标签
return {
iv: iv.toString('hex'),
encrypted,
authTag: authTag.toString('hex')
}
}
function decrypt(encrypted, iv, authTag) {
const decipher = crypto.createDecipheriv(
ALGORITHM,
ENCRYPTION_KEY,
Buffer.from(iv, 'hex')
)
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
// 使用示例
const sensitive = '用户敏感数据'
const encrypted = encrypt(sensitive)
// 存储到数据库:{ iv, encrypted, authTag }
const decrypted = decrypt(encrypted.encrypted, encrypted.iv, encrypted.authTag)
// ==================== 密码学安全随机数 ====================
function generateSecureToken(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex')
}
function generateUUID() {
return crypto.randomUUID()
}
// ==================== 数据脱敏 ====================
function maskEmail(email) {
const [localPart, domain] = email.split('@')
const masked = localPart.slice(0, 2) + '***'
return `${masked}@${domain}`
}
function maskPhone(phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
function maskIdCard(idCard) {
return idCard.replace(/(.{6}).*(.{4})/, '$1********$2')
}
function maskBankCard(cardNumber) {
return '**** **** **** ' + cardNumber.slice(-4)
}
// API 响应脱敏
function sanitizeUserData(user) {
return {
id: user._id,
username: user.username,
email: maskEmail(user.email),
phone: user.phone ? maskPhone(user.phone) : undefined,
// 完全排除敏感字段
// password, token, apiKey 等不返回
}
}密钥管理最佳实践
javascript
// ==================== 环境变量管理 ====================
// 使用 convict 进行配置验证
const convict = require('convict')
const config = convict({
env: {
doc: '应用环境',
format: ['production', 'development', 'test'],
default: 'development',
env: 'NODE_ENV'
},
database: {
url: {
doc: '数据库连接 URL',
format: String,
default: 'mongodb://localhost:27017/app',
env: 'DATABASE_URL',
sensitive: true
}
},
jwt: {
accessSecret: {
doc: 'JWT 访问令牌密钥',
format: String,
default: '',
env: 'JWT_ACCESS_SECRET',
sensitive: true
},
refreshSecret: {
doc: 'JWT 刷新令牌密钥',
format: String,
default: '',
env: 'JWT_REFRESH_SECRET',
sensitive: true
}
},
encryption: {
key: {
doc: '数据加密密钥(32字节hex)',
format: String,
default: '',
env: 'ENCRYPTION_KEY',
sensitive: true
}
}
})
// 验证配置
config.validate({ allowed: 'strict' })
// ==================== 密钥轮换 ====================
// 支持多密钥验证,新令牌使用新密钥
const JWT_SECRETS = [
process.env.JWT_SECRET_NEW, // 新密钥(用于签名)
process.env.JWT_SECRET_OLD // 旧密钥(用于验证过渡期)
].filter(Boolean)
function verifyTokenWithRotation(token) {
for (const secret of JWT_SECRETS) {
try {
return jwt.verify(token, secret)
} catch (e) {
continue
}
}
throw new Error('Invalid token')
}
// ==================== 密钥生成 ====================
// 生成安全的密钥
function generateEncryptionKey() {
return crypto.randomBytes(32).toString('hex')
}
function generateJwtSecret() {
return crypto.randomBytes(64).toString('hex')
}
// 首次部署时生成密钥
// node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"依赖安全与漏洞防护
依赖安全检查
bash
# 安全最佳实践
# 检查漏洞
npm audit
# 自动修复
npm audit fix
# 强制修复(可能有破坏性变更)
npm audit fix --force
# 指定严重级别
npm audit --audit-level=high
# JSON 格式输出
npm audit --json
# ==================== Snyk 深度扫描 ====================
# 安装 Snyk
npm install -g snyk
# 认证
snyk auth
# 扫描
snyk test
# 监控项目
snyk monitor
# 自动修复
snyk wizard
# ==================== 其他工具 ====================
# 检查过期依赖
npx npm-check-updates
# 检查未使用的依赖
npx depcheck
# 检查依赖许可证
npx license-checker --summaryCI/CD 安全检查配置
yaml
# .github/workflows/security.yml
name: Security Audit
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
schedule:
- cron: '0 0 * * 0' # 每周检查
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run npm audit
run: npm audit --audit-level=high
continue-on-error: true
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
command: test
- name: Run Snyk Code (SAST)
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
command: code test
- name: Check for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}package.json 安全配置
json
{
"name": "secure-app",
"version": "1.0.0",
"scripts": {
"audit": "npm audit --audit-level=moderate",
"audit:fix": "npm audit fix",
"snyk": "snyk test",
"preinstall": "npx npm-force-resolutions",
"postinstall": "npm run audit"
},
"dependencies": {
"express": "4.18.2",
"helmet": "7.1.0",
"bcrypt": "5.1.1"
},
"devDependencies": {
"snyk": "1.1260.0"
},
"engines": {
"node": ">=18.0.0"
},
"resolutions": {
"lodash": ">=4.17.21",
"minimist": ">=1.2.6"
}
}.npmrc 安全配置
ini
# .npmrc
# 禁止运行脚本(防止恶意脚本)
ignore-scripts=true
# 使用官方 registry
registry=https://registry.npmjs.org/
# 使用 package-lock
package-lock=true
# 使用严格 SSL
strict-ssl=true
# 验证签名
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
# 缓存配置
cache=/tmp/npm-cache速率限制与防护
基础速率限制
javascript
const rateLimit = require('express-rate-limit')
// ==================== 全局限制 ====================
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每个 IP 最多 100 次请求
message: {
error: '请求过于频繁,请稍后重试',
code: 'RATE_LIMIT_EXCEEDED'
},
standardHeaders: true, // 返回 RateLimit-* 头
legacyHeaders: false,
// 跳过特定请求
skip: (req) => {
const whitelist = ['127.0.0.1', '::1']
return whitelist.includes(req.ip)
}
})
app.use(globalLimiter)
// ==================== 严格限制(登录等)====================
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 5, // 最多 5 次尝试
skipSuccessfulRequests: true,
message: {
error: '登录尝试次数过多,请 15 分钟后重试',
code: 'LOGIN_RATE_LIMIT'
}
})
app.post('/login', loginLimiter, loginHandler)
app.post('/forgot-password', loginLimiter, forgotPasswordHandler)
// ==================== API 分级限制 ====================
const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 分钟
max: (req) => {
// 根据用户等级设置不同限制
if (req.user?.plan === 'enterprise') return 1000
if (req.user?.plan === 'pro') return 100
return 20 // 免费用户
},
keyGenerator: (req) => {
// 已登录用户使用 userId
return req.user?.userId || req.ip
}
})
app.use('/api/', apiLimiter)
// ==================== 分布式限制(Redis)====================
const RedisStore = require('rate-limit-redis')
const redis = require('redis')
const redisClient = redis.createClient({
url: process.env.REDIS_URL
})
const distributedLimiter = rateLimit({
store: new RedisStore({
sendCommand: (...args) => redisClient.sendCommand(args)
}),
windowMs: 60 * 1000,
max: 100
})DDoS 防护
javascript
// ==================== 连接限制 ====================
const express = require('express')
const http = require('http')
const app = express()
const server = http.createServer(app)
// 最大连接数
server.maxConnections = 1000
// 连接超时
server.timeout = 120000 // 2 分钟
server.keepAliveTimeout = 65000
// ==================== 请求体大小限制 ====================
app.use(express.json({ limit: '1mb' }))
app.use(express.urlencoded({ extended: true, limit: '1mb' }))
// ==================== Slowloris 攻击防护 ====================
const slowDown = require('express-slow-down')
const speedLimiter = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 100, // 100 次请求后开始延迟
delayMs: 500 // 每次请求增加 500ms 延迟
})
app.use(speedLimiter)
// ==================== 超时处理 ====================
const timeout = require('connect-timeout')
app.use(timeout('30s'))
app.use((req, res, next) => {
if (!req.timedout) next()
})
// ==================== 健康检查(不限制)====================
app.get('/health', (req, res) => {
res.json({ status: 'ok' })
})
// 从速率限制中排除
app.set('trust proxy', 1)错误处理与日志安全
安全的错误处理
javascript
// ==================== 自定义错误类 ====================
class AppError extends Error {
constructor(message, statusCode, code) {
super(message)
this.statusCode = statusCode
this.code = code
this.isOperational = true
Error.captureStackTrace(this, this.constructor)
}
}
class ValidationError extends AppError {
constructor(message) {
super(message, 400, 'VALIDATION_ERROR')
}
}
class AuthenticationError extends AppError {
constructor(message = '认证失败') {
super(message, 401, 'AUTHENTICATION_ERROR')
}
}
class AuthorizationError extends AppError {
constructor(message = '权限不足') {
super(message, 403, 'AUTHORIZATION_ERROR')
}
}
class NotFoundError extends AppError {
constructor(message = '资源不存在') {
super(message, 404, 'NOT_FOUND')
}
}
// ==================== 全局错误处理 ====================
app.use((err, req, res, next) => {
// 记录错误(服务器端)
logger.error('请求错误', {
message: err.message,
stack: err.stack,
url: req.originalUrl,
method: req.method,
ip: req.ip,
userId: req.user?.userId,
body: sanitizeLog(req.body),
query: req.query
})
// 判断是否为已知错误
const isOperational = err.isOperational || false
// 生产环境返回简化信息
if (process.env.NODE_ENV === 'production') {
if (isOperational) {
// 已知错误,返回具体信息
return res.status(err.statusCode || 500).json({
error: err.message,
code: err.code
})
}
// 未知错误,返回通用信息
return res.status(500).json({
error: '服务器内部错误',
code: 'INTERNAL_ERROR'
})
}
// 开发环境返回详细错误
res.status(err.statusCode || 500).json({
error: err.message,
code: err.code,
stack: err.stack,
details: err.details || null
})
})
// ==================== 异步错误捕获 ====================
// 包装异步路由处理器
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
// 使用示例
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id)
if (!user) {
throw new NotFoundError('用户不存在')
}
res.json(user)
}))
// ==================== 未捕获异常处理 ====================
process.on('uncaughtException', (error) => {
logger.error('未捕获异常', error)
// 优雅关闭
process.exit(1)
})
process.on('unhandledRejection', (reason, promise) => {
logger.error('未处理的 Promise 拒绝', { reason, promise })
process.exit(1)
})日志安全
javascript
const winston = require('winston')
// ==================== 安全日志配置 ====================
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: {
service: 'nodejs-app',
env: process.env.NODE_ENV
},
transports: [
// 错误日志
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// 安全事件日志
new winston.transports.File({
filename: 'logs/security.log',
level: 'warn'
}),
// 综合日志
new winston.transports.File({
filename: 'logs/combined.log',
maxsize: 10485760, // 10MB
maxFiles: 10
})
]
})
// 开发环境控制台输出
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}))
}
// ==================== 日志脱敏 ====================
const SENSITIVE_FIELDS = [
'password', 'pass', 'pwd',
'token', 'accessToken', 'refreshToken',
'secret', 'apiKey', 'api_key',
'creditCard', 'cardNumber',
'ssn', 'idCard'
]
function sanitizeLog(data) {
if (typeof data !== 'object' || data === null) {
return data
}
const sanitized = Array.isArray(data) ? [] : {}
for (const [key, value] of Object.entries(data)) {
const lowerKey = key.toLowerCase()
if (SENSITIVE_FIELDS.some(field => lowerKey.includes(field))) {
sanitized[key] = '[REDACTED]'
} else if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeLog(value)
} else {
sanitized[key] = value
}
}
return sanitized
}
// ==================== 安全事件日志 ====================
const securityLogger = {
login: (userId, ip, success) => {
logger.warn('LOGIN_ATTEMPT', {
userId,
ip,
success,
timestamp: new Date().toISOString()
})
},
failedAuth: (ip, reason) => {
logger.warn('AUTH_FAILURE', {
ip,
reason,
timestamp: new Date().toISOString()
})
},
suspiciousRequest: (req, reason) => {
logger.warn('SUSPICIOUS_REQUEST', {
ip: req.ip,
method: req.method,
url: req.originalUrl,
headers: sanitizeLog(req.headers),
reason,
timestamp: new Date().toISOString()
})
},
dataAccess: (userId, resource, action) => {
logger.info('DATA_ACCESS', {
userId,
resource,
action,
timestamp: new Date().toISOString()
})
},
permissionDenied: (userId, resource, action) => {
logger.warn('PERMISSION_DENIED', {
userId,
resource,
action,
timestamp: new Date().toISOString()
})
}
}文件上传安全
安全文件上传配置
javascript
const multer = require('multer')
const path = require('path')
const fs = require('fs').promises
const crypto = require('crypto')
// ==================== 配置常量 ====================
const UPLOAD_CONFIG = {
// 允许的文件类型
allowedMimes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
'text/plain'
],
// 允许的扩展名
allowedExtensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.pdf', '.txt'],
// 文件大小限制
maxSize: 10 * 1024 * 1024, // 10MB
// 上传目录
uploadDir: path.resolve('./uploads')
}
// ==================== 存储配置 ====================
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
// 按日期分目录
const dateDir = new Date().toISOString().split('T')[0]
const uploadPath = path.join(UPLOAD_CONFIG.uploadDir, dateDir)
try {
await fs.mkdir(uploadPath, { recursive: true })
cb(null, uploadPath)
} catch (error) {
cb(error)
}
},
filename: (req, file, cb) => {
// 生成安全的文件名
const ext = path.extname(file.originalname).toLowerCase()
const safeName = `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`
cb(null, safeName)
}
})
// ==================== 文件过滤器 ====================
const fileFilter = (req, file, cb) => {
// 检查 MIME 类型
if (!UPLOAD_CONFIG.allowedMimes.includes(file.mimetype)) {
return cb(new Error('不支持的文件类型'), false)
}
// 检查扩展名
const ext = path.extname(file.originalname).toLowerCase()
if (!UPLOAD_CONFIG.allowedExtensions.includes(ext)) {
return cb(new Error('不支持的文件扩展名'), false)
}
cb(null, true)
}
// ==================== Multer 配置 ====================
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: UPLOAD_CONFIG.maxSize,
files: 5,
fields: 10,
fieldNameSize: 100
}
})
// ==================== 上传路由 ====================
// 单文件上传
app.post('/upload/single',
upload.single('file'),
async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: '请选择文件' })
}
// 验证文件内容
const isValid = await verifyFileContent(req.file)
if (!isValid) {
await fs.unlink(req.file.path) // 删除无效文件
return res.status(400).json({ error: '文件内容无效' })
}
// 扫描病毒(可选)
// await scanForVirus(req.file.path)
res.json({
success: true,
file: {
filename: req.file.filename,
size: req.file.size,
mimetype: req.file.mimetype
}
})
}
)
// 多文件上传
app.post('/upload/multiple',
upload.array('files', 5),
async (req, res) => {
res.json({
success: true,
files: req.files.map(f => ({
filename: f.filename,
size: f.size
}))
})
}
)
// ==================== 文件内容验证 ====================
async function verifyFileContent(file) {
// 检查文件魔数(真实类型)
const buffer = Buffer.alloc(8)
const fd = await fs.open(file.path, 'r')
await fd.read(buffer, 0, 8, 0)
await fd.close()
// 常见文件签名
const signatures = {
'image/jpeg': [0xFF, 0xD8, 0xFF],
'image/png': [0x89, 0x50, 0x4E, 0x47],
'image/gif': [0x47, 0x49, 0x46],
'application/pdf': [0x25, 0x50, 0x44, 0x46]
}
const expected = signatures[file.mimetype]
if (!expected) return true
return expected.every((byte, i) => buffer[i] === byte)
}
// ==================== 文件下载安全 ====================
app.get('/download/:filename', async (req, res) => {
const filename = path.basename(req.params.filename)
const filepath = path.join(UPLOAD_CONFIG.uploadDir, filename)
// 验证路径
if (!filepath.startsWith(UPLOAD_CONFIG.uploadDir)) {
return res.status(403).json({ error: '非法路径' })
}
// 检查文件是否存在
try {
await fs.access(filepath)
} catch {
return res.status(404).json({ error: '文件不存在' })
}
// 设置安全响应头
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
res.setHeader('X-Content-Type-Options', 'nosniff')
res.sendFile(filepath)
})生产环境安全检查清单
部署前检查
markdown
## 安全检查清单
### 认证与授权
- [ ] 使用 HTTPS(HSTS 配置)
- [ ] 密码使用 bcrypt 加密存储(salt rounds >= 10)
- [ ] JWT/Session 配置安全(过期时间、签名算法)
- [ ] 实施登录速率限制
- [ ] 敏感操作需要二次验证
- [ ] 权限最小化原则
### 输入验证
- [ ] 所有用户输入都经过验证
- [ ] 使用参数化查询/ORM
- [ ] 文件上传限制类型和大小
- [ ] 防止路径遍历
- [ ] 验证 Content-Type
### 安全头
- [ ] 使用 Helmet 中间件
- [ ] 配置 CSP(Content-Security-Policy)
- [ ] 设置 HSTS
- [ ] X-Frame-Options 设置
- [ ] 禁用不必要的 HTTP 方法
### 数据保护
- [ ] 敏感数据加密存储
- [ ] 使用环境变量管理密钥
- [ ] 数据库访问最小权限
- [ ] 定期备份数据
- [ ] 敏感数据脱敏显示
### 日志与监控
- [ ] 记录安全事件
- [ ] 日志不包含敏感信息
- [ ] 设置异常告警
- [ ] 定期审查日志
- [ ] 监控异常登录
### 依赖安全
- [ ] npm audit 无高危漏洞
- [ ] 锁定依赖版本(package-lock.json)
- [ ] 定期更新依赖
- [ ] 移除未使用的依赖
- [ ] 验证依赖来源
### 错误处理
- [ ] 生产环境隐藏详细错误
- [ ] 全局错误处理
- [ ] 异步错误捕获
- [ ] 优雅关闭服务
- [ ] 未捕获异常处理
### 其他
- [ ] 禁用调试模式
- [ ] 移除测试端点
- [ ] 使用非 root 用户运行
- [ ] 定期安全审计
- [ ] 实施渗透测试安全配置脚本
javascript
// scripts/security-check.js
const { exec } = require('child_process')
const fs = require('fs')
const path = require('path')
async function securityCheck() {
console.log('🔍 开始安全检查...\n')
const errors = []
const warnings = []
// 1. 检查环境变量
console.log('✓ 检查环境变量')
const requiredEnv = [
'JWT_ACCESS_SECRET',
'JWT_REFRESH_SECRET',
'DATABASE_URL',
'ENCRYPTION_KEY'
]
const missing = requiredEnv.filter(key => !process.env[key])
if (missing.length > 0) {
errors.push(`缺少环境变量: ${missing.join(', ')}`)
}
// 2. 检查 HTTPS
console.log('✓ 检查 HTTPS 配置')
if (process.env.NODE_ENV === 'production') {
if (!process.env.SSL_KEY || !process.env.SSL_CERT) {
warnings.push('生产环境建议配置 SSL 证书')
}
}
// 3. 检查密钥强度
console.log('✓ 检查密钥强度')
const secrets = [
{ name: 'JWT_ACCESS_SECRET', value: process.env.JWT_ACCESS_SECRET },
{ name: 'JWT_REFRESH_SECRET', value: process.env.JWT_REFRESH_SECRET }
]
for (const secret of secrets) {
if (secret.value && secret.value.length < 32) {
warnings.push(`${secret.name} 长度不足 32 字符`)
}
}
// 4. npm audit
console.log('✓ 运行 npm audit')
try {
await new Promise((resolve, reject) => {
exec('npm audit --audit-level=high --json', (error, stdout) => {
if (error) {
try {
const result = JSON.parse(stdout)
const highVulns = result.metadata?.vulnerabilities?.high || 0
const criticalVulns = result.metadata?.vulnerabilities?.critical || 0
if (highVulns > 0 || criticalVulns > 0) {
errors.push(`发现 ${criticalVulns} 个严重漏洞, ${highVulns} 个高危漏洞`)
}
} catch (e) {
warnings.push('npm audit 解析失败')
}
}
resolve()
})
})
} catch (e) {
warnings.push('npm audit 执行失败')
}
// 5. 检查敏感文件
console.log('✓ 检查敏感文件')
const sensitiveFiles = [
'.env',
'private.key',
'credentials.json',
'id_rsa'
]
for (const file of sensitiveFiles) {
if (fs.existsSync(path.join(process.cwd(), file))) {
errors.push(`发现敏感文件: ${file},请确保已加入 .gitignore`)
}
}
// 6. 检查 .gitignore
console.log('✓ 检查 .gitignore')
const gitignorePath = path.join(process.cwd(), '.gitignore')
if (fs.existsSync(gitignorePath)) {
const gitignore = fs.readFileSync(gitignorePath, 'utf8')
const requiredPatterns = ['.env', 'node_modules', '*.log']
for (const pattern of requiredPatterns) {
if (!gitignore.includes(pattern)) {
warnings.push(`.gitignore 缺少: ${pattern}`)
}
}
} else {
errors.push('缺少 .gitignore 文件')
}
// 7. 检查 package.json
console.log('✓ 检查 package.json 配置')
const packageJson = require('../package.json')
if (!packageJson.engines?.node) {
warnings.push('package.json 未指定 Node.js 版本')
}
// 输出结果
console.log('\n' + '='.repeat(50))
if (errors.length > 0) {
console.log('\n❌ 错误:')
errors.forEach(e => console.log(` - ${e}`))
}
if (warnings.length > 0) {
console.log('\n⚠️ 警告:')
warnings.forEach(w => console.log(` - ${w}`))
}
if (errors.length === 0 && warnings.length === 0) {
console.log('\n✅ 所有安全检查通过')
process.exit(0)
}
if (errors.length > 0) {
console.log('\n❌ 安全检查未通过,请修复上述错误')
process.exit(1)
}
console.log('\n⚠️ 安全检查通过,但存在警告')
process.exit(0)
}
securityCheck().catch(console.error)Node.js 安全编码规范
javascript
// ==================== 安全编码示例 ====================
// 1. 使用严格模式
'use strict'
// 2. 冻结敏感配置
const config = Object.freeze({
API_KEY: process.env.API_KEY,
DB_URL: process.env.DATABASE_URL
})
// 3. 使用 const/let 替代 var
const secret = process.env.JWT_SECRET
let counter = 0
// 4. 错误处理使用 try-catch
async function safeOperation() {
try {
const result = await riskyOperation()
return result
} catch (error) {
logger.error('操作失败', { error: error.message })
throw new AppError('操作失败', 500)
}
}
// 5. 使用 Promise.allSettled 处理并发
async function parallelOperations() {
const results = await Promise.allSettled([
fetchUser(),
fetchOrders(),
fetchSettings()
])
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`操作 ${index} 失败`, result.reason)
}
})
}
// 6. 安全的 JSON 解析
function safeJsonParse(str) {
try {
return JSON.parse(str)
} catch (error) {
logger.error('JSON 解析失败')
return null
}
}
// 7. 类型检查
function validateType(value, type) {
if (typeof value !== type) {
throw new TypeError(`期望 ${type},得到 ${typeof value}`)
}
return value
}
// 8. 禁止危险函数
function safeEval(expression) {
throw new Error('禁止使用 eval()')
}
// 9. 安全的正则表达式(避免 ReDoS)
function safeRegex(pattern, input) {
// 设置超时
const timeout = setTimeout(() => {
throw new Error('正则匹配超时')
}, 1000)
const result = pattern.test(input)
clearTimeout(timeout)
return result
}
// 10. 使用加密安全的随机数
const crypto = require('crypto')
function generateToken(bytes = 32) {
return crypto.randomBytes(bytes).toString('hex')
}
function generateUUID() {
return crypto.randomUUID()
}
// 11. 安全的对象操作
function safeAssign(target, source) {
const dangerous = ['__proto__', 'constructor', 'prototype']
const result = { ...target }
for (const key of Object.keys(source)) {
if (!dangerous.includes(key)) {
result[key] = source[key]
}
}
return result
}
// 12. 超时包装
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('操作超时')), ms)
)
])
}常见问题解答
Q1: 如何防止 SQL 注入?
javascript
// ❌ 危险:直接拼接
const sql = `SELECT * FROM users WHERE id = ${userId}`
// ✅ 安全:参数化查询
const [rows] = await pool.execute(
'SELECT * FROM users WHERE id = ?',
[userId]
)
// ✅ 安全:ORM 查询
const user = await User.findOne({ where: { id: userId } })Q2: 如何安全地存储密码?
javascript
// ✅ 使用 bcrypt
const bcrypt = require('bcrypt')
const SALT_ROUNDS = 12
// 存储
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS)
// 验证
const isValid = await bcrypt.compare(password, hashedPassword)Q3: 如何防止 XSS 攻击?
javascript
// 1. 输入验证
const { body } = require('express-validator')
body('content').trim().escape()
// 2. 输出编码
const escape = require('escape-html')
res.send(escape(userInput))
// 3. CSP 头
app.use(helmet.contentSecurityPolicy({
directives: { defaultSrc: ["'self'"] }
}))
// 4. httpOnly Cookie
res.cookie('sessionId', id, { httpOnly: true })Q4: 如何实现安全的 API 认证?
javascript
// JWT + 刷新令牌方案
const tokens = generateTokens(user)
// AccessToken: 短期有效,存内存
// RefreshToken: 长期有效,存 HttpOnly Cookie 或安全存储
// 刷新端点
app.post('/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken
if (!refreshToken) {
return res.status(401).json({ error: '缺少刷新令牌' })
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET)
const user = await User.findById(decoded.userId)
// 生成新的 AccessToken
const accessToken = jwt.sign(
{ userId: user._id },
ACCESS_SECRET,
{ expiresIn: '15m' }
)
res.json({ accessToken })
} catch (e) {
res.status(401).json({ error: '无效的刷新令牌' })
}
})Q5: 如何防止 CSRF 攻击?
javascript
// 1. SameSite Cookie
res.cookie('sessionId', id, { sameSite: 'strict' })
// 2. CSRF Token
const csrf = require('csurf')
app.use(csrf({ cookie: true }))
// 前端获取 Token
app.get('/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() })
})
// 前端请求时携带
fetch('/api/data', {
headers: { 'X-CSRF-Token': csrfToken }
})
// 3. 检查 Origin/Referer
app.use((req, res, next) => {
const origin = req.get('Origin') || req.get('Referer')
if (origin && !allowedOrigins.includes(new URL(origin).origin)) {
return res.status(403).json({ error: 'CSRF 检测' })
}
next()
})参考资源
官方文档
安全工具
- 漏洞扫描: npm audit、Snyk
- 静态分析: ESLint Security Plugin
- 依赖检查: npm-check-updates
- 密钥检测: git-secrets、truffleHog
学习资源
Node.js 22+ 安全新特性
权限模型
Node.js 22.13+ 权限模型已稳定,可限制应用对文件系统和网络的访问:
bash
# 限制文件系统和网络访问
node --permission \
--allow-fs-read=/app/data \
--allow-fs-read=/app/config \
--allow-fs-write=/app/logs \
--allow-fs-write=/app/uploads \
--allow-net=0.0.0.0:3000 \
--allow-net=db.internal:5432 \
app.jsjavascript
// 运行时检查权限
if (process.permission.has('fs.read', '/etc/passwd')) {
console.error('安全警告:应用不应读取系统文件')
}原生 crypto.randomUUID
无需导入 crypto 模块即可生成 UUID:
javascript
// Node.js 22+ 全局可用
const sessionId = crypto.randomUUID()
const csrfToken = crypto.randomUUID()Web Crypto API
Node.js 22+ 全局 crypto.subtle 可用,与浏览器 API 一致:
javascript
// 生成安全随机值
const salt = crypto.getRandomValues(new Uint8Array(16))
// SHA-256 哈希
const encoder = new TextEncoder()
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(password))
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
// AES-GCM 加密
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(sensitiveData)
)--env-file 避免密钥泄露
bash
# 替代 dotenv,避免密钥硬编码
node --env-file=.env.production app.js
# .env.production(不提交到 Git)
JWT_SECRET=your-256-bit-secret
DATABASE_URL=postgresql://user:pass@host:5432/db
ENCRYPTION_KEY=your-encryption-key