服务端安全
问题
服务端有哪些常见安全威胁?如何建立全面的安全防御体系?
答案
服务端安全涵盖从输入校验、认证授权、数据保护到运维监控的全链路防护。本文以 OWASP Top 10 为框架,结合实际工程落地方案,系统梳理服务端安全的核心知识。
安全威胁全景图
服务端安全威胁速览
| 威胁类型 | 说明 | 关键防御手段 |
|---|---|---|
| SQL / NoSQL 注入 | 恶意输入篡改查询语句 | 参数化查询、ORM、输入校验 |
| 命令注入 | 执行恶意系统命令 | execFile + 参数数组、禁用 exec |
| SSRF | 服务端发起恶意请求 | URL 白名单、DNS 重绑定防护 |
| 越权访问 | 水平 / 垂直越权 | 数据归属校验、RBAC |
| 认证绕过 | 暴力破解、凭证泄露 | bcrypt、MFA、Token 轮换 |
| 文件上传漏洞 | 上传恶意文件执行 | 类型白名单、隔离存储、杀毒 |
| 信息泄露 | 错误信息暴露内部细节 | 统一错误格式、日志脱敏 |
| 安全配置错误 | 默认密码、调试接口暴露 | 安全基线检查、环境隔离 |
| 依赖漏洞 | 第三方包含已知漏洞 | npm audit、Snyk、锁定版本 |
| DoS / 业务滥用 | 恶意刷接口、爬数据 | 限流、CAPTCHA、行为分析 |
1. 注入攻击
注入攻击是 OWASP Top 10 常年排名前三的安全威胁。核心原则:永远不信任用户输入。
SQL 注入
// ❌ 危险:字符串拼接 SQL
const query = `SELECT * FROM users WHERE id = '${userId}'`;
// 攻击: userId = "'; DROP TABLE users; --"
// ✅ 参数化查询(MySQL2)
const [rows] = await connection.execute(
'SELECT * FROM users WHERE id = ? AND status = ?',
[userId, 'active'],
);
// ✅ 参数化查询(PostgreSQL)
const result = await pool.query(
'SELECT * FROM users WHERE id = $1 AND status = $2',
[userId, 'active'],
);
// ✅ ORM 自动防注入(Prisma)
const user = await prisma.user.findUnique({
where: { id: userId },
});
即使使用 ORM,如果用了 $queryRaw 或 $executeRaw 等原始查询方法,仍然存在注入风险:
// ❌ 依然危险
await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE name = '${name}'`);
// ✅ 使用模板标签
await prisma.$queryRaw`SELECT * FROM users WHERE name = ${name}`;
NoSQL 注入
// ❌ 危险:直接使用用户输入作为查询条件
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password,
});
// 攻击: { "password": { "$ne": "" } } → 绕过密码验证
// ✅ 使用 Zod 强制类型校验
import { z } from 'zod';
const loginSchema = z.object({
username: z.string().min(1).max(50),
password: z.string().min(6).max(100),
});
const { username, password } = loginSchema.parse(req.body);
// password 一定是 string,不可能是 { "$ne": "" }
命令注入
import { execFile } from 'child_process';
// ❌ 危险:exec + 字符串拼接
exec(`ping -c 1 ${req.query.host}`);
// 攻击: ?host=127.0.0.1; rm -rf /
// ✅ execFile + 参数数组(参数不会被 Shell 解释)
execFile('ping', ['-c', '1', host], (err, stdout) => {
res.send(stdout);
});
// ✅ 更好的方案:输入校验 + execFile
const hostSchema = z.string().regex(/^[\w.-]+$/).max(253);
const validHost = hostSchema.parse(req.query.host);
execFile('ping', ['-c', '1', validHost], callback);
路径遍历
import path from 'path';
// ❌ 危险:直接拼接
const filePath = `./uploads/${req.params.name}`;
// 攻击: /files/../../../etc/passwd
// ✅ 路径规范化 + 前缀校验
const basePath = path.resolve('./uploads');
const filePath = path.resolve(basePath, req.params.name);
if (!filePath.startsWith(basePath + path.sep)) {
return res.status(403).json({ error: 'Access denied' });
}
res.sendFile(filePath);
2. SSRF(服务端请求伪造)
SSRF(Server-Side Request Forgery)是攻击者诱导服务器向非预期地址发起请求,可用于:
- 探测内网服务(如数据库、缓存)
- 读取云服务元数据(如 AWS
169.254.169.254) - 绕过防火墙访问内部 API
import { URL } from 'url';
import dns from 'dns/promises';
import { isIP } from 'net';
// 内网 IP 段检测
function isPrivateIP(ip: string): boolean {
const parts = ip.split('.').map(Number);
return (
ip === '127.0.0.1' ||
ip === '0.0.0.0' ||
parts[0] === 10 ||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254)
);
}
async function safeFetch(urlString: string): Promise<Response> {
const url = new URL(urlString);
// 1. 协议白名单
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('Protocol not allowed');
}
// 2. 禁止直接使用 IP
if (isIP(url.hostname)) {
if (isPrivateIP(url.hostname)) {
throw new Error('Private IP not allowed');
}
}
// 3. DNS 解析后检查(防止 DNS 重绑定攻击)
const addresses = await dns.resolve4(url.hostname);
if (addresses.some(isPrivateIP)) {
throw new Error('Resolved to private IP');
}
// 4. 域名白名单
const allowedDomains = ['api.github.com', 'cdn.example.com'];
if (!allowedDomains.some(d => url.hostname.endsWith(d))) {
throw new Error('Domain not allowed');
}
// 5. 设置超时,避免慢连接探测
return fetch(urlString, {
signal: AbortSignal.timeout(5000),
redirect: 'error', // 禁止重定向(防止 302 绕过)
});
}
攻击者控制一个域名,第一次 DNS 解析返回正常 IP(通过白名单检查),第二次解析返回内网 IP。防御方式:DNS 解析后检查 IP,并将解析结果直接用于连接。
3. 访问控制
访问控制缺陷是 2021 年 OWASP Top 10 的 第一名。
水平越权 vs 垂直越权
| 类型 | 说明 | 示例 | 防御 |
|---|---|---|---|
| 水平越权 | 访问同级别用户的数据 | 用户 A 访问用户 B 的订单 | 查询强制绑定 userId |
| 垂直越权 | 低权限执行高权限操作 | 普通用户调用管理员接口 | RBAC 中间件校验 |
// ✅ 水平越权防御:查询强制绑定当前用户
async function getOrder(orderId: string, currentUserId: string) {
const order = await prisma.order.findFirst({
where: { id: orderId, userId: currentUserId },
});
if (!order) {
throw new ForbiddenError('无权访问');
}
return order;
}
// ✅ 垂直越权防御:RBAC 中间件
function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// 只有 admin 可以删除用户
app.delete('/api/users/:id', requireRole('admin'), deleteUser);
// admin 和 editor 都可以编辑文章
app.put('/api/posts/:id', requireRole('admin', 'editor'), updatePost);
IDOR(不安全的直接对象引用)
// ❌ 危险:URL 中暴露自增 ID,容易被遍历
// GET /api/invoices/1001、/api/invoices/1002...
// ✅ 使用 UUID 替代自增 ID
import { randomUUID } from 'crypto';
const invoice = await prisma.invoice.create({
data: { id: randomUUID(), userId: currentUser.id, amount: 100 },
});
// ✅ 即使使用 UUID,仍需做归属校验
const invoice = await prisma.invoice.findFirst({
where: { id: invoiceId, userId: currentUser.id },
});
4. 认证安全
完整的认证鉴权方案(Session、JWT、OAuth、SSO)详见 认证鉴权体系。
密码存储
import bcrypt from 'bcrypt';
import { z } from 'zod';
// 密码复杂度校验
const passwordSchema = z
.string()
.min(8, '至少 8 个字符')
.max(100, '不超过 100 个字符')
.regex(/[A-Z]/, '需包含大写字母')
.regex(/[a-z]/, '需包含小写字母')
.regex(/[0-9]/, '需包含数字');
// 存储:bcrypt 哈希(cost factor >= 12)
async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
// 验证
async function verifyPassword(input: string, hash: string): Promise<boolean> {
return bcrypt.compare(input, hash);
}
- MD5 / SHA 系列是通用哈希,速度极快,容易被彩虹表或 GPU 暴力破解
- bcrypt / scrypt / Argon2 是密码哈希,内置盐值和可调计算成本,专为密码设计
暴力破解防御
import rateLimit from 'express-rate-limit';
// 1. 登录接口严格限流
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 5, // 最多 5 次失败
skipSuccessfulRequests: true,
message: { error: '登录尝试过多,请 15 分钟后重试' },
});
app.post('/api/login', loginLimiter, loginHandler);
// 2. 账户锁定机制
async function handleLoginFailure(userId: string): Promise<void> {
const key = `login:fail:${userId}`;
const failCount = await redis.incr(key);
await redis.expire(key, 900); // 15 分钟
if (failCount >= 5) {
await prisma.user.update({
where: { id: userId },
data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) },
});
}
}
// 3. 不要暴露用户是否存在
// ❌ "用户名不存在" / "密码错误"
// ✅ "用户名或密码错误"
5. 输入校验
所有来自客户端的数据都不可信,包括 body、query、params、headers、cookie。
import { z } from 'zod';
// 为每个接口定义严格的 Schema
const createPostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
tags: z.array(z.string().max(20)).max(10).optional(),
categoryId: z.string().uuid(),
});
// 通用校验中间件
function validate<T>(schema: z.ZodType<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.issues.map(i => ({
path: i.path.join('.'),
message: i.message,
})),
});
}
req.body = result.data; // 用校验后的数据替换原始数据
next();
};
}
app.post('/api/posts', validate(createPostSchema), createPost);
| 层面 | 示例 | 工具 |
|---|---|---|
| 类型 | 字符串、数字、布尔 | Zod / Joi |
| 格式 | email、URL、UUID | Zod 内置 |
| 范围 | 长度 1-200、数值 0-100 | min / max |
| 白名单 | 枚举值、允许的字段 | z.enum / z.literal |
| 业务规则 | 开始时间 < 结束时间 | z.refine |
6. 安全响应头
import helmet from 'helmet';
// Helmet 一键配置常用安全头
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
| 响应头 | 作用 | 推荐值 |
|---|---|---|
Content-Security-Policy | 限制资源加载来源,防 XSS | 按需配置 |
Strict-Transport-Security | 强制 HTTPS | max-age=31536000; includeSubDomains |
X-Content-Type-Options | 防止 MIME 类型嗅探 | nosniff |
X-Frame-Options | 防止点击劫持 | DENY |
X-XSS-Protection | 浏览器 XSS 过滤 | 0(依赖 CSP 代替) |
Referrer-Policy | 控制 Referer 泄露 | strict-origin-when-cross-origin |
Permissions-Policy | 控制浏览器 API 权限 | 按需禁用摄像头、麦克风等 |
Cross-Origin-Opener-Policy | 隔离顶级窗口 | same-origin |
Cross-Origin-Embedder-Policy | 控制嵌入资源 | require-corp |
7. CORS 配置
错误的 CORS 配置可能让恶意网站读取你的 API 数据。
import cors from 'cors';
// ❌ 危险:允许所有来源
app.use(cors({ origin: '*', credentials: true }));
// origin: * + credentials: true 实际上会被浏览器拒绝,
// 但有些库会变通处理,结果就是任意网站都能发认证请求
// ❌ 危险:反射 Origin(动态返回请求的 Origin)
app.use(cors({
origin: (origin, callback) => callback(null, origin),
credentials: true,
}));
// ✅ 安全:白名单校验
const allowedOrigins = [
'https://example.com',
'https://admin.example.com',
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push('http://localhost:3000');
}
app.use(cors({
origin: (origin, callback) => {
// 允许无 Origin 的请求(如服务端调用)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // 预检缓存 24 小时
}));
8. 文件上传安全
文件上传是高危攻击面,可导致远程代码执行、存储型 XSS、DoS 等。
import multer from 'multer';
import path from 'path';
import crypto from 'crypto';
import { fileTypeFromBuffer } from 'file-type';
// 1. 限制文件大小和数量
const upload = multer({
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5,
},
storage: multer.memoryStorage(),
});
// 2. 校验文件类型(不能只看扩展名!)
const ALLOWED_TYPES = new Map([
['image/jpeg', '.jpg'],
['image/png', '.png'],
['image/webp', '.webp'],
['application/pdf', '.pdf'],
]);
async function validateFile(
buffer: Buffer,
originalName: string,
): Promise<string> {
// 检查 Magic Bytes(文件头签名)
const type = await fileTypeFromBuffer(buffer);
if (!type || !ALLOWED_TYPES.has(type.mime)) {
throw new Error(`File type not allowed: ${type?.mime || 'unknown'}`);
}
// 检查扩展名是否匹配
const ext = path.extname(originalName).toLowerCase();
if (ext !== ALLOWED_TYPES.get(type.mime)) {
throw new Error('Extension does not match file type');
}
return type.mime;
}
// 3. 重命名文件(防止路径遍历和文件名攻击)
function generateSafeFilename(ext: string): string {
return `${crypto.randomUUID()}${ext}`;
}
// 4. 存储到隔离位置(OSS / 独立域名,不放在应用目录)
app.post('/api/upload', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file' });
const mime = await validateFile(req.file.buffer, req.file.originalname);
const ext = ALLOWED_TYPES.get(mime)!;
const filename = generateSafeFilename(ext);
// 上传到 OSS(非本地存储)
await oss.put(`uploads/${filename}`, req.file.buffer, {
headers: {
'Content-Type': mime,
'Content-Disposition': 'attachment', // 强制下载,不在浏览器直接打开
},
});
res.json({ url: `https://cdn.example.com/uploads/${filename}` });
});
- 不信任 Content-Type:攻击者可以伪造,要检查 Magic Bytes
- 不信任文件名:用 UUID 重命名,防止
../../etc/passwd - 限制大小:防止大文件 DoS
- 隔离存储:文件不要存在应用服务器的可执行目录
- 独立域名:上传文件用单独域名提供(如
cdn.example.com),防止同源 XSS - 图片处理:对图片做一次 re-encode(如 Sharp),可以清除嵌入的恶意代码
9. 错误处理与信息泄露
// ❌ 危险:暴露内部信息
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({
error: err.message, // 可能包含 SQL 语句、文件路径
stack: err.stack, // 暴露代码结构
query: (err as any).sql, // 暴露 SQL 查询
});
});
// ✅ 安全:区分已知错误和未知错误
class AppError extends Error {
constructor(
public statusCode: number,
public userMessage: string,
public details?: unknown,
) {
super(userMessage);
}
}
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// 已知业务错误:返回友好信息
if (err instanceof AppError) {
return res.status(err.statusCode).json({
error: err.userMessage,
});
}
// 未知错误:只返回通用信息,详细错误记入日志
logger.error('Unhandled error', {
error: err.message,
stack: err.stack,
url: req.url,
method: req.method,
userId: req.user?.id,
});
res.status(500).json({
error: 'Internal server error',
// 开发环境可以返回详情
...(process.env.NODE_ENV === 'development' && {
debug: { message: err.message, stack: err.stack },
}),
});
});
- 错误响应:SQL 语句、文件路径、堆栈信息
- 响应头:
X-Powered-By: Express(用app.disable('x-powered-by')关闭) - API 文档:生产环境关闭 Swagger UI
- 版本号暴露:
Server: nginx/1.22.0(Nginx 配置server_tokens off) - 日志:密码、Token、身份证号等敏感信息误入日志
10. 密钥管理
// ✅ 从环境变量读取,永远不硬编码
const config = {
dbUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
apiKey: process.env.API_KEY!,
};
// ✅ 启动时校验必需配置
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
API_KEY: z.string().min(16),
NODE_ENV: z.enum(['development', 'production', 'test']),
});
// 启动时立即校验,缺失则直接退出
const env = envSchema.parse(process.env);
| 场景 | 工具 | 说明 |
|---|---|---|
| 本地开发 | .env + dotenv | .env 加入 .gitignore |
| CI/CD | GitHub Secrets / GitLab CI Variables | 加密存储 |
| 生产环境 | AWS Secrets Manager / Vault | 自动轮换、访问审计 |
| 容器化 | Kubernetes Secrets | 挂载为环境变量或文件 |
- 将密钥提交到 Git 仓库(即使后来删除,历史记录中仍然存在)
- 前端代码中暴露 API Key(需要通过后端代理)
- 多环境共用同一密钥
- 密钥从不轮换
11. 依赖安全
第三方依赖是服务端安全的重要攻击面。
# npm 内置审计
npm audit
npm audit fix
# pnpm
pnpm audit
# Snyk 深度检测
npx snyk test
npx snyk monitor # 持续监控
# 检查过时依赖
npx npm-check-updates
{
"scripts": {
// CI 中自动审计,高危漏洞直接失败
"audit:ci": "npm audit --audit-level=high",
"postinstall": "npm audit --audit-level=high || true"
},
// 强制覆盖有漏洞的间接依赖版本
"overrides": {
"vulnerable-package": "^2.0.1"
}
}
- 锁定版本:提交
package-lock.json/pnpm-lock.yaml - CI 自动审计:在 Pipeline 中加入
npm audit - 最小依赖原则:能不用的包就不用,减少攻击面
- 定期更新:使用 Dependabot / Renovate 自动创建更新 PR
- 私有 Registry:企业内部使用 Verdaccio 或 Nexus 代理
12. 防刷与反滥用
防刷不仅是技术问题,更是业务安全问题。攻击类型多样,需要多层防御。
攻击类型
| 类型 | 场景 | 危害 |
|---|---|---|
| 接口刷量 | 恶意请求轰炸 API | 服务不可用、资源耗尽 |
| 业务薅羊毛 | 批量注册刷优惠券 | 经济损失 |
| 撞库攻击 | 用泄露的密码库批量尝试登录 | 账号被盗 |
| 爬虫抓取 | 批量爬取商品、价格、内容 | 数据泄露、竞争风险 |
| 短信轰炸 | 恶意触发验证码发送 | 费用损失、用户骚扰 |
| 刷票/刷赞 | 自动化刷票、投票、点赞 | 业务数据失真 |
多层防御体系
第一层:速率限制
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis();
// 全局限流:每 IP 每分钟 60 次
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args: string[]) => redis.call(...args),
}),
});
// 敏感接口限流:更严格
const sensitiveLimiter = rateLimit({
windowMs: 60 * 1000,
max: 5,
keyGenerator: (req) => {
// 已登录用户按 userId 限流,未登录按 IP
return req.user?.id || req.ip;
},
});
// 短信验证码限流:同一手机号 1 分钟 1 次
const smsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 1,
keyGenerator: (req) => `sms:${req.body.phone}`,
});
app.use('/api/', globalLimiter);
app.post('/api/login', sensitiveLimiter, loginHandler);
app.post('/api/sms/send', smsLimiter, sendSmsHandler);
第二层:人机验证
// 后端校验 reCAPTCHA / hCaptcha / Turnstile Token
async function verifyCaptcha(token: string): Promise<boolean> {
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
secret: process.env.TURNSTILE_SECRET_KEY,
response: token,
}),
},
);
const data = await response.json();
return data.success === true;
}
app.post('/api/register', async (req, res) => {
const captchaValid = await verifyCaptcha(req.body.captchaToken);
if (!captchaValid) {
return res.status(400).json({ error: '人机验证失败' });
}
// ...正常注册逻辑
});
第三层:行为分析与风控
interface RequestFingerprint {
ip: string;
userAgent: string;
userId?: string;
deviceId?: string;
requestInterval: number; // 请求间隔
}
async function detectAbnormalBehavior(
fp: RequestFingerprint,
): Promise<{ isBot: boolean; reason?: string }> {
// 1. 请求间隔过于均匀(脚本特征)
const intervals = await getRecentIntervals(fp.ip, 20);
const variance = calculateVariance(intervals);
if (variance < 10) {
return { isBot: true, reason: 'uniform_interval' };
}
// 2. 同一设备/IP 短时间内大量不同账号登录
const accountCount = await redis.scard(`ip:accounts:${fp.ip}`);
if (accountCount > 10) {
return { isBot: true, reason: 'multi_account' };
}
// 3. User-Agent 异常(空、过短、已知爬虫标识)
if (!fp.userAgent || fp.userAgent.length < 10) {
return { isBot: true, reason: 'invalid_ua' };
}
return { isBot: false };
}
针对具体业务的防刷方案
// 优惠券防刷
async function claimCoupon(userId: string, couponId: string): Promise<void> {
// 1. 同一用户同一优惠券只能领一次
const claimed = await redis.sismember(`coupon:${couponId}:users`, userId);
if (claimed) throw new AppError(400, '已领取过该优惠券');
// 2. 同一设备 ID 限制
const deviceClaimed = await redis.sismember(
`coupon:${couponId}:devices`,
deviceId,
);
if (deviceClaimed) throw new AppError(400, '该设备已领取');
// 3. 原子操作防并发
const result = await redis.eval(
`
if redis.call('sismember', KEYS[1], ARGV[1]) == 1 then
return 0
end
redis.call('sadd', KEYS[1], ARGV[1])
return 1
`,
1,
`coupon:${couponId}:users`,
userId,
);
if (result === 0) throw new AppError(400, '领取失败');
}
// 短信验证码防刷
async function sendSms(phone: string, ip: string): Promise<void> {
// 1. 同一手机号 1 分钟 1 次
const phoneKey = `sms:phone:${phone}`;
if (await redis.exists(phoneKey)) {
throw new AppError(429, '发送太频繁,请稍后再试');
}
// 2. 同一手机号每天最多 10 次
const dailyKey = `sms:daily:${phone}`;
const dailyCount = Number(await redis.get(dailyKey)) || 0;
if (dailyCount >= 10) {
throw new AppError(429, '今日发送次数已达上限');
}
// 3. 同一 IP 每小时最多 20 次(防止遍历手机号轰炸)
const ipKey = `sms:ip:${ip}`;
const ipCount = Number(await redis.get(ipKey)) || 0;
if (ipCount >= 20) {
throw new AppError(429, '请求过多');
}
await sendSmsMessage(phone);
await redis.set(phoneKey, '1', 'EX', 60);
await redis.incr(dailyKey);
await redis.expire(dailyKey, 86400);
await redis.incr(ipKey);
await redis.expire(ipKey, 3600);
}
13. 日志与审计
安全事件发生后,日志是定位问题的唯一依据。
import pino from 'pino';
const logger = pino({
level: 'info',
// 自动脱敏
redact: {
paths: ['req.headers.authorization', 'req.body.password', 'req.body.token'],
censor: '[REDACTED]',
},
});
// 记录安全相关事件
function logSecurityEvent(event: {
type: 'auth_failure' | 'auth_success' | 'permission_denied'
| 'rate_limited' | 'suspicious_activity' | 'data_access';
userId?: string;
ip: string;
details: Record<string, unknown>;
}): void {
logger.warn({ ...event, timestamp: new Date().toISOString() }, 'SECURITY_EVENT');
}
// 示例:登录失败
logSecurityEvent({
type: 'auth_failure',
ip: req.ip,
details: { username: req.body.username, reason: 'invalid_password' },
});
// 示例:敏感数据访问
logSecurityEvent({
type: 'data_access',
userId: req.user.id,
ip: req.ip,
details: { resource: 'user_list', action: 'export' },
});
- 必须记录:登录成功/失败、权限变更、敏感操作(删除、导出)、异常请求
- 必须脱敏:密码、Token、身份证号、银行卡号
- 必须包含:时间戳、IP、UserAgent、用户 ID、操作内容
- 定期审查:设置告警规则,异常登录模式自动报警
14. 安全测试
| 测试类型 | 工具 | 说明 |
|---|---|---|
| 依赖审计 | npm audit / Snyk | 检查已知漏洞 |
| SAST | SonarQube / Semgrep | 静态代码扫描 |
| DAST | OWASP ZAP / Burp Suite | 动态渗透测试 |
| 密钥泄露 | GitLeaks / TruffleHog | 扫描代码仓库中的密钥 |
| 安全头检测 | securityheaders.com | 检测 HTTP 安全头 |
# GitLeaks:扫描 Git 历史中的密钥
npx gitleaks detect --source=.
# Semgrep:静态代码安全扫描
npx @semgrep/semgrep --config=auto
安全防御清单
常见面试问题
Q1: 如何防止 SQL 注入?
答案:
核心原则是数据与代码分离:
- 参数化查询:使用
?或$1占位符,数据库引擎自动转义 - ORM:Prisma / TypeORM 等 ORM 自动处理参数绑定
- 输入校验:用 Zod 校验类型和格式
- 最小权限:数据库账号只授予必需权限(如只读账号用于查询)
// 参数化查询
await pool.query('SELECT * FROM users WHERE id = $1 AND role = $2', [id, role]);
// ORM(Prisma 自动防注入)
await prisma.user.findMany({ where: { name: { contains: input } } });
Q2: SSRF 攻击是什么?怎么防御?
答案:
SSRF 是攻击者让服务器代替自己发起请求,常见场景:
- 用户提交 URL 让服务端抓取(如图片下载、网页预览)
- Webhook 回调地址
防御措施:
- 协议白名单:只允许 http/https
- IP 检查:DNS 解析后检查是否为内网 IP(防 DNS 重绑定)
- 域名白名单:只允许已知域名
- 禁止重定向:
redirect: 'error',防止 302 绕过 - 网络隔离:应用服务器和内网服务不在同一网络
Q3: 如何防止越权访问?
答案:
| 类型 | 防御方式 |
|---|---|
| 水平越权 | 所有数据查询强制加 userId 条件 |
| 垂直越权 | RBAC 中间件统一校验角色权限 |
| IDOR | 使用 UUID 替代自增 ID + 归属校验 |
// 水平越权防御
const order = await prisma.order.findFirst({
where: { id: orderId, userId: currentUser.id },
});
// 垂直越权防御
app.delete('/api/users/:id', requireRole('admin'), deleteUser);
Q4: 密码应该如何存储?
答案:
永远不存明文,使用专门的密码哈希算法:
| 算法 | 推荐 | 说明 |
|---|---|---|
| bcrypt | ✅ | 最广泛使用,cost factor >= 12 |
| scrypt | ✅ | 抵抗 GPU/ASIC 攻击 |
| Argon2 | ✅ 最佳 | 2015 年密码哈希竞赛冠军 |
| MD5 / SHA | ❌ | 太快,容易被暴力破解 |
// bcrypt
const hash = await bcrypt.hash(password, 12);
const match = await bcrypt.compare(input, hash);
Q5: 速率限制有哪些维度?
答案:
| 维度 | 场景 | 示例 |
|---|---|---|
| IP | 全局防护 | 每 IP 每分钟 60 次 |
| 用户 | 已登录用户 | 每用户每分钟 30 次 |
| 接口 | 敏感操作 | 登录接口每小时 5 次 |
| 手机号 | 短信防刷 | 每号每分钟 1 次、每天 10 次 |
| 设备 | 多账号防刷 | 同设备每天 3 个账号 |
生产环境建议使用 Redis 存储(支持多实例共享),详见 限流与熔断。
Q6: 文件上传有哪些安全风险?
答案:
| 风险 | 说明 | 防御 |
|---|---|---|
| 恶意文件执行 | 上传 .php、.jsp 被服务器执行 | 文件类型白名单 + 隔离存储 |
| 路径遍历 | 文件名 ../../etc/passwd | UUID 重命名 |
| 存储型 XSS | 上传 .html / .svg 含脚本 | 独立域名、Content-Disposition: attachment |
| 大文件 DoS | 上传超大文件耗尽磁盘 | 限制大小(multer limits) |
| MIME 欺骗 | 改扩展名绕过检查 | 检查 Magic Bytes(file-type 库) |
安全检查顺序:大小限制 → 扩展名白名单 → Magic Bytes 校验 → UUID 重命名 → 隔离存储。
Q7: 生产环境应该设置哪些安全响应头?
答案:
使用 helmet 一行代码配置:
import helmet from 'helmet';
app.use(helmet());
最重要的几个:
- CSP:限制资源加载来源,防 XSS(最复杂但最重要)
- HSTS:强制 HTTPS,防中间人
- X-Content-Type-Options: nosniff:防 MIME 嗅探
- X-Frame-Options: DENY:防点击劫持
Q8: 如何防止业务被刷?
答案:
多层递进防御:
- 速率限制(基础):IP / 用户 / 接口维度限流
- 人机验证(进阶):登录、注册等关键节点加 CAPTCHA
- 行为分析(高级):检测请求间隔均匀度、UA 异常、多账号等
- 风控引擎(完整):结合设备指纹、IP 画像、用户行为等维度综合判定
具体到业务场景:
- 优惠券:一人一次 + 设备去重 + Redis 原子操作
- 短信验证码:同号限频 + 同 IP 限频 + 图形验证码前置
- 投票/点赞:登录态 + IP 去重 + 频率限制
Q9: 如何安全管理密钥和敏感配置?
答案:
- 永远不提交到 Git(
.env加入.gitignore) - 使用环境变量或密钥管理服务(如 AWS Secrets Manager、HashiCorp Vault)
- CI/CD 中使用加密变量(GitHub Secrets)
- 定期轮换密钥
- 启动时用 Zod 校验必需的环境变量
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
envSchema.parse(process.env); // 缺失则启动失败
Q10: 前后端安全职责如何划分?
答案:
| 层面 | 前端 | 后端 |
|---|---|---|
| 输入校验 | 用户体验(即时反馈) | 安全保障(不可绕过) |
| XSS | React 自动转义、DOMPurify | CSP 头、HttpOnly Cookie |
| CSRF | 携带 CSRF Token | 校验 Token、SameSite Cookie |
| 认证 | 存储 Token、自动续期 | 签发、验证、撤销 Token |
| 权限 | 按钮/路由隐藏 | API 层强制校验 |
| 加密 | HTTPS(浏览器自动) | TLS 证书、密码哈希 |
前端的一切校验都可以被绕过(用户可以直接用 curl / Postman 调接口),安全校验必须在后端执行。前端校验只是提升用户体验。
Q11: CORS 配置有哪些常见错误?
答案:
| 错误配置 | 风险 | 正确做法 |
|---|---|---|
origin: '*' + credentials: true | 任意网站可发认证请求 | 域名白名单 |
| 反射 Origin(动态返回请求的 Origin) | 等价于 * | 白名单校验 |
允许 null Origin | 沙箱 iframe 可利用 | 不允许 null |
Access-Control-Allow-Methods: * | 暴露所有 HTTP 方法 | 只允许需要的方法 |
Q12: 如何进行安全审计和测试?
答案:
| 类型 | 时机 | 工具 |
|---|---|---|
| 依赖审计 | 每次 CI | npm audit / Snyk |
| SAST(静态扫描) | PR 合并前 | SonarQube / Semgrep |
| DAST(动态测试) | 定期 / 上线前 | OWASP ZAP / Burp Suite |
| 密钥泄露检测 | 每次 commit | GitLeaks / pre-commit hook |
| 渗透测试 | 每季度 | 专业安全团队 |
Q13: 如何处理安全漏洞应急?
答案:
发现漏洞 → 评估影响 → 紧急止血 → 修复上线 → 复盘改进
- 评估影响:受影响用户范围、数据是否泄露
- 紧急止血:关闭相关接口 / 限流 / 下线功能
- 修复上线:修复漏洞 + 热修复上线
- 事后处理:通知受影响用户、重置凭证
- 复盘改进:分析根因、补充自动化检测
Q14: 服务端安全清单有哪些?
答案:
const securityChecklist = {
// 输入层
input: '所有输入用 Zod/Joi 校验',
sql: '使用参数化查询或 ORM',
upload: '文件类型白名单 + Magic Bytes 校验',
// 认证层
password: 'bcrypt/Argon2 哈希,cost >= 12',
jwt: 'Access Token 15 分钟过期,Refresh Token 轮换',
bruteForce: '登录限流 + 账户锁定',
// 授权层
horizontal: '查询强制绑定 userId',
vertical: 'RBAC 中间件统一校验',
// 通信层
https: '生产环境强制 HTTPS',
headers: 'Helmet 设置安全头',
cors: '域名白名单,禁止 origin: *',
// 运维层
secrets: '环境变量 + 密钥管理服务',
dependencies: 'CI 自动审计 + Dependabot',
logging: '安全事件日志 + 告警',
rateLimit: '多维度速率限制',
};
Q15: 什么是零信任安全模型?
答案:
零信任(Zero Trust)的核心是 "永不信任,始终验证",即使请求来自内网也需要认证和授权。
| 传统模型 | 零信任模型 |
|---|---|
| 信任内网,只防外网 | 内外网一视同仁 |
| 一次认证,长期有效 | 持续验证,最短有效期 |
| 宽泛的网络访问权限 | 最小权限原则 |
| VPN 即可访问内部资源 | 每次访问都需认证 |
前端/BFF 层面的落地:
- 所有 API 调用都携带 Token,且 Token 短期过期
- 服务间调用也需要认证(mTLS / Service Mesh)
- 按接口粒度做权限控制,而非按网络区域
相关链接
- 浏览器安全 - XSS、CSRF、CSP、点击劫持
- Node.js 安全 - Node.js 安全实践
- 认证鉴权体系 - Session、JWT、OAuth、SSO
- 限流与熔断 - 限流算法、熔断器、降级
- 服务端安全 - 密钥管理、日志审计
- OWASP Top 10 - Web 应用安全十大风险
- OWASP Node.js 安全指南