Skip to content

安全实践

一、安全威胁概览

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 helmet
typescript
// 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: '*' 允许任意来源生产环境明确指定允许的域名列表

NestJS 深度学习体系