安全实践
一、安全威胁概览
Web 应用常见的 OWASP Top 10 威胁及 NestJS 对应防御:
| 威胁 | 描述 | NestJS 防御手段 |
|---|---|---|
| 注入(SQL/NoSQL) | 用户输入拼接进查询 | ORM 参数化查询、ValidationPipe whitelist |
| 失效的认证 | Token 泄露、弱密码 | JWT + bcrypt + rate limiting |
| 敏感数据暴露 | 明文传输、日志泄露 | HTTPS + Helmet + 字段 select:false |
| 越权访问 | 水平/垂直越权 | RBAC + CASL 资源级权限 |
| 安全配置错误 | 默认配置不安全 | Helmet + CORS 严格配置 |
| XSS | 注入恶意脚本 | HttpOnly Cookie + CSP Header |
| CSRF | 跨站请求伪造 | SameSite Cookie + CSRF Token |
| 过度暴露数据 | 返回多余字段 | DTO + ClassSerializerInterceptor |
二、Helmet(HTTP 安全头)
bash
npm install helmettypescript
// main.ts
import helmet from 'helmet';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 设置多个安全响应头
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // 允许内联样式(Swagger 需要)
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
crossOriginEmbedderPolicy: false, // 某些 CDN 资源需要关闭
}));
await app.listen(3000);
}Helmet 自动设置的响应头:
| Header | 防御 |
|---|---|
X-Content-Type-Options: nosniff | 防止 MIME 嗅探 |
X-Frame-Options: DENY | 防止点击劫持 |
Strict-Transport-Security | 强制 HTTPS |
X-XSS-Protection: 0 | 禁用旧版 XSS 过滤(推荐 CSP 代替) |
Referrer-Policy: no-referrer | 控制 Referrer 信息 |
三、CORS 配置
typescript
// main.ts
app.enableCors({
// 生产环境严格限制来源
origin: (origin, callback) => {
const allowedOrigins = [
'https://yourapp.com',
'https://admin.yourapp.com',
];
// 允许非浏览器工具(Postman、curl)访问 API
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('CORS 策略不允许此来源'));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['X-Total-Count'], // 前端可以读取的响应头
credentials: true, // 允许携带 Cookie
maxAge: 86400, // 预检请求缓存 24 小时
});四、Rate Limiting(限流)
bash
npm install @nestjs/throttler基础配置
typescript
// app.module.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => [{
ttl: config.get('THROTTLE_TTL', 60000), // 时间窗口(ms)
limit: config.get('THROTTLE_LIMIT', 100), // 窗口内最大请求数
}],
}),
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard }, // 全局限流
],
})
export class AppModule {}精细控制
typescript
// 登录接口:更严格的限流(防暴力破解)
@Throttle({ default: { ttl: 60000, limit: 5 } }) // 1分钟最多5次
@Post('login')
login(@Body() dto: LoginDto) {}
// 公开接口:放宽限制
@Throttle({ default: { ttl: 60000, limit: 200 } })
@Public()
@Get('posts')
findAll() {}
// 跳过限流(内部接口)
@SkipThrottle()
@Get('health')
health() {}基于 Redis 的分布式限流
typescript
// 多实例部署时,需要共享计数
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
ThrottlerModule.forRoot({
throttlers: [{ ttl: 60000, limit: 100 }],
storage: new ThrottlerStorageRedisService({
host: 'localhost',
port: 6379,
}),
})五、输入验证与净化
全局 ValidationPipe
typescript
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // 自动剥离 DTO 中未定义的字段
forbidNonWhitelisted: true, // 发现非白名单字段时返回 400
transform: true, // 自动类型转换(string → number 等)
transformOptions: {
enableImplicitConversion: true, // 路由参数自动转换类型
},
disableErrorMessages: process.env.NODE_ENV === 'production', // 生产不泄露验证细节
}));防 SQL 注入
typescript
// ❌ 危险:字符串拼接
await this.userRepo.query(`SELECT * FROM users WHERE email = '${email}'`);
// ✅ 参数化查询(TypeORM)
await this.userRepo.query('SELECT * FROM users WHERE email = $1', [email]);
// ✅ QueryBuilder 自动参数化
await this.userRepo
.createQueryBuilder('user')
.where('user.email = :email', { email }) // email 自动转义
.getOne();
// ✅ Prisma 自动参数化
await this.prisma.user.findUnique({ where: { email } });
// ✅ Prisma 原生 SQL 参数化
await this.prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
// ⚠️ 使用模板字面量,Prisma 自动转义;不要用 $queryRawUnsafe + 字符串拼接防 XSS
typescript
// 安装 sanitize-html
npm install sanitize-html @types/sanitize-html
// 在 DTO 中净化 HTML 字段
import sanitizeHtml from 'sanitize-html';
import { Transform } from 'class-transformer';
export class CreatePostDto {
@IsString()
title: string;
@IsString()
@Transform(({ value }) => sanitizeHtml(value, {
allowedTags: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
allowedAttributes: { 'a': ['href', 'target'] },
}))
content: string; // 富文本内容,允许部分标签
}六、密码安全
typescript
import * as bcrypt from 'bcrypt';
const SALT_ROUNDS = 12; // 推荐值:10-14(越高越慢,越安全)
// ── 注册时 ──
const hashedPassword = await bcrypt.hash(plainPassword, SALT_ROUNDS);
// 存入数据库的是哈希值,约 60 字符
// ── 登录验证 ──
const isMatch = await bcrypt.compare(plainPassword, storedHash);
// bcrypt.compare 是恒定时间比较,防止时序攻击
// ── Entity 配置(查询时不返回密码)──
@Column({ select: false })
password: string;
// ── 需要验证时显式查询 ──
const user = await this.userRepo
.createQueryBuilder('user')
.where('user.email = :email', { email })
.addSelect('user.password') // 显式 select password
.getOne();七、响应数据脱敏
typescript
// 使用 class-transformer 控制序列化输出
import { Exclude, Expose, Transform } from 'class-transformer';
import { ClassSerializerInterceptor } from '@nestjs/common';
// Entity / Response DTO
export class UserResponseDto {
id: number;
email: string;
name: string;
@Exclude() // 不包含在响应中
password: string;
@Exclude()
refreshToken: string;
@Transform(({ value }) => value?.toISOString().split('T')[0])
createdAt: Date; // 只返回日期部分 "2024-03-10"
// 动态脱敏(手机号中间4位替换为****)
@Transform(({ value }) => value?.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'))
phone?: string;
}
// 全局启用序列化拦截器
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// Controller 声明返回类型
@SerializeOptions({ type: UserResponseDto })
@Get('me')
getProfile(@CurrentUser() user: User): UserResponseDto {
return plainToInstance(UserResponseDto, user);
}八、环境变量安全
typescript
// config/app.config.ts
import { registerAs } from '@nestjs/config';
import * as Joi from 'joi';
export default registerAs('app', () => ({
jwtSecret: process.env.JWT_SECRET,
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET,
databaseUrl: process.env.DATABASE_URL,
}));
// 启动时校验必要的环境变量
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
DATABASE_URL: Joi.string().uri().required(),
JWT_SECRET: Joi.string().min(32).required(),
JWT_REFRESH_SECRET: Joi.string().min(32).required(),
PORT: Joi.number().default(3000),
}),
validationOptions: {
abortEarly: false, // 报告所有验证错误,不是第一个
},
}),
],
})
export class AppModule {}.env 文件安全守则:
bash
# .gitignore - 永远不要提交 .env 到 Git!
.env
.env.local
.env.production
# 提交 .env.example(只含键名,不含值)
JWT_SECRET=
DATABASE_URL=九、生产安全清单
typescript
// main.ts 生产配置
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn'], // 生产环境不打 debug/verbose 日志
});
// 安全中间件
app.use(helmet());
app.use(compression());
// 严格 CORS
app.enableCors({ origin: process.env.ALLOWED_ORIGINS?.split(',') });
// 全局管道
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
disableErrorMessages: true, // ← 生产环境隐藏验证细节
}));
// 全局过滤器(防止内部错误信息泄露)
app.useGlobalFilters(new GlobalExceptionFilter());
// 隐藏框架信息
app.getHttpAdapter().getInstance().disable('x-powered-by');
await app.listen(process.env.PORT ?? 3000, '0.0.0.0');
}typescript
// 全局异常过滤器:生产环境不暴露内部错误
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
// 生产环境:5xx 错误只返回通用信息
const message = (status >= 500 && process.env.NODE_ENV === 'production')
? '服务器内部错误'
: isHttpException
? exception.message
: '未知错误';
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}十、安全相关依赖版本管理
bash
# 定期检查依赖中的已知漏洞
npm audit
# 自动修复低风险漏洞
npm audit fix
# 查看具体漏洞详情
npm audit --json | jq '.vulnerabilities'
# 使用 Snyk 做持续安全扫描(CI 集成)
npx snyk test可运行 Demo:
practice/04-auth-system— ValidationPipe 全局校验、密码 bcrypt 哈希、敏感字段排除
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
class-validator 校验不生效 | ValidationPipe 未全局注册 | app.useGlobalPipes(new ValidationPipe({ whitelist: true })) |
whitelist: true 删除了需要的字段 | 字段没有任何 class-validator 装饰器 | 所有需要保留的字段加上 @IsOptional() 或其他验证器 |
| SQL 注入漏洞(TypeORM) | 用字符串拼接构建 WHERE 条件 | 永远用参数化查询:.where('id = :id', { id }) |
| 密码明文存储 | 忘记 bcrypt hash | 注册/修改密码时 bcrypt.hash(password, 10),不要存原文 |
| CORS 配置过宽 | origin: '*' 允许任意来源 | 生产环境明确指定允许的域名列表 |