跳到主要内容

服务端安全

问题

服务端有哪些常见安全威胁?如何建立全面的安全防御体系?

答案

服务端安全涵盖从输入校验、认证授权、数据保护到运维监控的全链路防护。本文以 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-injection-defense.ts
// ❌ 危险:字符串拼接 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 注入

nosql-injection-defense.ts
// ❌ 危险:直接使用用户输入作为查询条件
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": "" }

命令注入

command-injection-defense.ts
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);

路径遍历

path-traversal-defense.ts
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
ssrf-prevention.ts
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 重绑定攻击

攻击者控制一个域名,第一次 DNS 解析返回正常 IP(通过白名单检查),第二次解析返回内网 IP。防御方式:DNS 解析后检查 IP,并将解析结果直接用于连接


3. 访问控制

访问控制缺陷是 2021 年 OWASP Top 10 的 第一名

水平越权 vs 垂直越权

类型说明示例防御
水平越权访问同级别用户的数据用户 A 访问用户 B 的订单查询强制绑定 userId
垂直越权低权限执行高权限操作普通用户调用管理员接口RBAC 中间件校验
access-control.ts
// ✅ 水平越权防御:查询强制绑定当前用户
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(不安全的直接对象引用)

idor-prevention.ts
// ❌ 危险: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)详见 认证鉴权体系

密码存储

password-security.ts
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 / SHA256?
  • MD5 / SHA 系列是通用哈希,速度极快,容易被彩虹表或 GPU 暴力破解
  • bcrypt / scrypt / Argon2 是密码哈希,内置盐值和可调计算成本,专为密码设计

暴力破解防御

brute-force-defense.ts
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。

input-validation.ts
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、UUIDZod 内置
范围长度 1-200、数值 0-100min / max
白名单枚举值、允许的字段z.enum / z.literal
业务规则开始时间 < 结束时间z.refine

6. 安全响应头

security-headers.ts
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强制 HTTPSmax-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 数据。

cors-config.ts
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 等。

file-upload-security.ts
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}` });
});
文件上传安全清单
  1. 不信任 Content-Type:攻击者可以伪造,要检查 Magic Bytes
  2. 不信任文件名:用 UUID 重命名,防止 ../../etc/passwd
  3. 限制大小:防止大文件 DoS
  4. 隔离存储:文件不要存在应用服务器的可执行目录
  5. 独立域名:上传文件用单独域名提供(如 cdn.example.com),防止同源 XSS
  6. 图片处理:对图片做一次 re-encode(如 Sharp),可以清除嵌入的恶意代码

9. 错误处理与信息泄露

error-handling.ts
// ❌ 危险:暴露内部信息
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. 密钥管理

secret-management.ts
// ✅ 从环境变量读取,永远不硬编码
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/CDGitHub Secrets / GitLab CI Variables加密存储
生产环境AWS Secrets Manager / Vault自动轮换、访问审计
容器化Kubernetes Secrets挂载为环境变量或文件
常见错误
  • 将密钥提交到 Git 仓库(即使后来删除,历史记录中仍然存在)
  • 前端代码中暴露 API Key(需要通过后端代理)
  • 多环境共用同一密钥
  • 密钥从不轮换

11. 依赖安全

第三方依赖是服务端安全的重要攻击面。

dependency-audit.sh
# npm 内置审计
npm audit
npm audit fix

# pnpm
pnpm audit

# Snyk 深度检测
npx snyk test
npx snyk monitor # 持续监控

# 检查过时依赖
npx npm-check-updates
package.json
{
"scripts": {
// CI 中自动审计,高危漏洞直接失败
"audit:ci": "npm audit --audit-level=high",
"postinstall": "npm audit --audit-level=high || true"
},
// 强制覆盖有漏洞的间接依赖版本
"overrides": {
"vulnerable-package": "^2.0.1"
}
}
供应链安全最佳实践
  1. 锁定版本:提交 package-lock.json / pnpm-lock.yaml
  2. CI 自动审计:在 Pipeline 中加入 npm audit
  3. 最小依赖原则:能不用的包就不用,减少攻击面
  4. 定期更新:使用 Dependabot / Renovate 自动创建更新 PR
  5. 私有 Registry:企业内部使用 Verdaccio 或 Nexus 代理

12. 防刷与反滥用

防刷不仅是技术问题,更是业务安全问题。攻击类型多样,需要多层防御。

攻击类型

类型场景危害
接口刷量恶意请求轰炸 API服务不可用、资源耗尽
业务薅羊毛批量注册刷优惠券经济损失
撞库攻击用泄露的密码库批量尝试登录账号被盗
爬虫抓取批量爬取商品、价格、内容数据泄露、竞争风险
短信轰炸恶意触发验证码发送费用损失、用户骚扰
刷票/刷赞自动化刷票、投票、点赞业务数据失真

多层防御体系

第一层:速率限制

rate-limiting.ts
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);

第二层:人机验证

captcha-verification.ts
// 后端校验 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: '人机验证失败' });
}
// ...正常注册逻辑
});

第三层:行为分析与风控

behavior-analysis.ts
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 };
}

针对具体业务的防刷方案

business-anti-abuse.ts
// 优惠券防刷
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. 日志与审计

安全事件发生后,日志是定位问题的唯一依据。

security-logging.ts
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检查已知漏洞
SASTSonarQube / Semgrep静态代码扫描
DASTOWASP ZAP / Burp Suite动态渗透测试
密钥泄露GitLeaks / TruffleHog扫描代码仓库中的密钥
安全头检测securityheaders.com检测 HTTP 安全头
CI 安全检查
# GitLeaks:扫描 Git 历史中的密钥
npx gitleaks detect --source=.

# Semgrep:静态代码安全扫描
npx @semgrep/semgrep --config=auto

安全防御清单


常见面试问题

Q1: 如何防止 SQL 注入?

答案

核心原则是数据与代码分离

  1. 参数化查询:使用 ?$1 占位符,数据库引擎自动转义
  2. ORM:Prisma / TypeORM 等 ORM 自动处理参数绑定
  3. 输入校验:用 Zod 校验类型和格式
  4. 最小权限:数据库账号只授予必需权限(如只读账号用于查询)
// 参数化查询
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 回调地址

防御措施:

  1. 协议白名单:只允许 http/https
  2. IP 检查:DNS 解析后检查是否为内网 IP(防 DNS 重绑定)
  3. 域名白名单:只允许已知域名
  4. 禁止重定向redirect: 'error',防止 302 绕过
  5. 网络隔离:应用服务器和内网服务不在同一网络

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/passwdUUID 重命名
存储型 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: 如何防止业务被刷?

答案

多层递进防御:

  1. 速率限制(基础):IP / 用户 / 接口维度限流
  2. 人机验证(进阶):登录、注册等关键节点加 CAPTCHA
  3. 行为分析(高级):检测请求间隔均匀度、UA 异常、多账号等
  4. 风控引擎(完整):结合设备指纹、IP 画像、用户行为等维度综合判定

具体到业务场景:

  • 优惠券:一人一次 + 设备去重 + Redis 原子操作
  • 短信验证码:同号限频 + 同 IP 限频 + 图形验证码前置
  • 投票/点赞:登录态 + IP 去重 + 频率限制

Q9: 如何安全管理密钥和敏感配置?

答案

  1. 永远不提交到 Git(.env 加入 .gitignore
  2. 使用环境变量或密钥管理服务(如 AWS Secrets Manager、HashiCorp Vault)
  3. CI/CD 中使用加密变量(GitHub Secrets)
  4. 定期轮换密钥
  5. 启动时用 Zod 校验必需的环境变量
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
envSchema.parse(process.env); // 缺失则启动失败

Q10: 前后端安全职责如何划分?

答案

层面前端后端
输入校验用户体验(即时反馈)安全保障(不可绕过)
XSSReact 自动转义、DOMPurifyCSP 头、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: 如何进行安全审计和测试?

答案

类型时机工具
依赖审计每次 CInpm audit / Snyk
SAST(静态扫描)PR 合并前SonarQube / Semgrep
DAST(动态测试)定期 / 上线前OWASP ZAP / Burp Suite
密钥泄露检测每次 commitGitLeaks / pre-commit hook
渗透测试每季度专业安全团队

Q13: 如何处理安全漏洞应急?

答案

发现漏洞 → 评估影响 → 紧急止血 → 修复上线 → 复盘改进
  1. 评估影响:受影响用户范围、数据是否泄露
  2. 紧急止血:关闭相关接口 / 限流 / 下线功能
  3. 修复上线:修复漏洞 + 热修复上线
  4. 事后处理:通知受影响用户、重置凭证
  5. 复盘改进:分析根因、补充自动化检测

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)
  • 按接口粒度做权限控制,而非按网络区域

相关链接