Skip to content

第 6 篇:输入校验 —— 安全的第一道防线

在前面的文章中,我们探讨了异常处理等重要话题。现在,让我们关注另一个基础但至关重要的横切关注点:输入校验。

输入校验是系统安全的第一道防线,也是防止数据污染和恶意攻击的重要手段。不正确的输入校验可能导致数据损坏、安全漏洞甚至系统崩溃。

DTO 校验(Zod / Joi / class-validator)

在 NestJS 中,有多种方式进行输入校验,我们来比较几种主流方案:

1. class-validator 方案(NestJS 官方推荐)

typescript
// 用户创建 DTO
import { IsEmail, IsNotEmpty, MinLength, MaxLength, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateUserDto {
  @IsNotEmpty()
  @MaxLength(50)
  username: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @MinLength(8)
  @MaxLength(100)
  password: string;

  @IsOptional()
  @MaxLength(100)
  firstName?: string;

  @IsOptional()
  @MaxLength(100)
  lastName?: string;
}

// 用户更新 DTO(所有字段可选)
export class UpdateUserDto {
  @IsOptional()
  @MaxLength(50)
  username?: string;

  @IsOptional()
  @IsEmail()
  email?: string;

  @IsOptional()
  @MinLength(8)
  @MaxLength(100)
  password?: string;

  @IsOptional()
  @MaxLength(100)
  firstName?: string;

  @IsOptional()
  @MaxLength(100)
  lastName?: string;
}

// 在控制器中使用
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async createUser(@Body() createUserDto: CreateUserDto) {
    // class-validator 会自动校验 DTO
    return this.userService.create(createUserDto);
  }

  @Patch(':id')
  async updateUser(
    @Param('id') id: string,
    @Body() updateUserDto: UpdateUserDto,
  ) { 
    return this.userService.update(id, updateUserDto);
  }
}

2. Zod 方案(类型安全的模式校验)

typescript
// 使用 Zod 定义模式
import { z } from 'zod';

// 用户创建模式
export const createUserSchema = z.object({
  username: z.string().min(1).max(50),
  email: z.string().email().max(100),
  password: z.string().min(8).max(100),
  firstName: z.string().max(100).optional(),
  lastName: z.string().max(100).optional(),
});

// 用户更新模式(所有字段可选)
export const updateUserSchema = createUserSchema.partial();

// Zod 校验管道
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: z.ZodSchema) {}

  transform(value: unknown) {
    try {
      const parsed = this.schema.parse(value);
      return parsed;
    } catch (error) {
      if (error instanceof z.ZodError) {
        const errors = error.errors.map((err) => ({
          path: err.path.join('.'),
          message: err.message,
        }));
        throw new BadRequestException({
          message: 'Validation failed',
          errors,
        });
      }
      throw error;
    }
  }
}

// 在控制器中使用 Zod
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async createUser(
    @Body(new ZodValidationPipe(createUserSchema)) createUserDto: z.infer<typeof createUserSchema>,
  ) {
    return this.userService.create(createUserDto);
  }

  @Patch(':id')
  async updateUser(
    @Param('id') id: string,
    @Body(new ZodValidationPipe(updateUserSchema)) updateUserDto: z.infer<typeof updateUserSchema>,
  ) {
    return this.userService.update(id, updateUserDto);
  }
}

3. 自定义校验装饰器

typescript
// 自定义手机号校验装饰器
import { registerDecorator, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';

@ValidatorConstraint({ async: false })
export class IsPhoneNumberConstraint implements ValidatorConstraintInterface {
  validate(phoneNumber: any) {
    if (typeof phoneNumber !== 'string') {
      return false;
    }
    
    // 简单的手机号格式校验(以 + 开头的国际格式)
    return /^\+\d{10,15}$/.test(phoneNumber);
  }

  defaultMessage() {
    return 'Invalid phone number format. Expected format: +1234567890';
  }
}

export function IsPhoneNumber(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: [],
      validator: IsPhoneNumberConstraint,
    });
  };
}

// 使用自定义装饰器
export class CreateUserDto {
  @IsNotEmpty()
  @MaxLength(50)
  username: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @IsPhoneNumber()
  phoneNumber: string;

  @IsNotEmpty()
  @MinLength(8)
  @MaxLength(100)
  password: string;
}

防止 overposting(用户提交 extra 字段篡改数据)

Overposting 是一种常见的安全风险,用户可能通过提交额外字段来篡改不应该被修改的数据:

typescript
// 错误示例:直接使用 DTO 更新数据库实体
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  email: string;

  @Column()
  password: string;

  @Column()
  isAdmin: boolean; // 危险字段:不应该由用户直接修改

  @Column()
  tenantId: string; // 危险字段:租户隔离字段
}

// 错误的做法
@Injectable()
export class UserService {
  async update(id: string, updateUserDto: UpdateUserDto) {
    // 危险:直接将 DTO 合并到实体中
    await this.userRepository.update(id, updateUserDto);
  }
}

// 正确的做法:白名单机制
@Injectable()
export class UserService {
  private readonly allowedUpdateFields = [
    'username',
    'email',
    'firstName',
    'lastName',
  ];

  async update(id: string, updateUserDto: UpdateUserDto) {
    // 只更新允许的字段
    const updateData = {};
    for (const field of this.allowedUpdateFields) {
      if (field in updateUserDto) {
        updateData[field] = updateUserDto[field];
      }
    }

    await this.userRepository.update(id, updateData);
  }
}

// 使用 class-transformer 过滤字段
import { classToClass, Exclude, Expose } from 'class-transformer';

export class SafeUserUpdateDto {
  @Expose()
  @IsOptional()
  @MaxLength(50)
  username?: string;

  @Expose()
  @IsOptional()
  @IsEmail()
  email?: string;

  @Expose()
  @IsOptional()
  @MaxLength(100)
  firstName?: string;

  @Expose()
  @IsOptional()
  @MaxLength(100)
  lastName?: string;

  // 不暴露的字段不会被包含在转换结果中
  @Exclude()
  isAdmin?: boolean;

  @Exclude()
  tenantId?: string;
}

@Injectable()
export class UserService {
  async update(id: string, updateUserDto: UpdateUserDto) {
    // 转换为安全的 DTO
    const safeDto = classToClass(SafeUserUpdateDto, updateUserDto);
    
    await this.userRepository.update(id, safeDto);
  }
}

校验失败的友好提示(国际化支持)

为了让用户更好地理解校验失败的原因,我们需要提供友好的错误提示,并支持国际化:

typescript
// 多语言错误消息
export const validationMessages = {
  en: {
    'username.isNotEmpty': 'Username is required',
    'username.maxLength': 'Username must be less than 50 characters',
    'email.isEmail': 'Invalid email format',
    'password.minLength': 'Password must be at least 8 characters',
  },
  zh: {
    'username.isNotEmpty': '用户名是必填项',
    'username.maxLength': '用户名不能超过50个字符',
    'email.isEmail': '邮箱格式不正确',
    'password.minLength': '密码至少需要8个字符',
  },
};

// 国际化校验管道
@Injectable()
export class I18nValidationPipe implements PipeTransform {
  constructor(
    private readonly i18nService: I18nService,
  ) {}

  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }

    const object = plainToClass(metatype, value);
    const errors = await validate(object);

    if (errors.length > 0) {
      const translatedErrors = await this.translateErrors(errors);
      throw new BadRequestException({
        message: 'Validation failed',
        errors: translatedErrors,
      });
    }

    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }

  private async translateErrors(errors: ValidationError[]): Promise<any[]> {
    const currentLang = this.i18nService.getCurrentLang();
    const messages = validationMessages[currentLang] || validationMessages.en;

    return errors.map(error => ({
      property: error.property,
      constraints: error.constraints 
        ? Object.keys(error.constraints).map(key => ({
            type: key,
            message: messages[`${error.property}.${key}`] || error.constraints[key],
          }))
        : [],
      children: error.children 
        ? await this.translateErrors(error.children) 
        : [],
    }));
  }
}

// 在控制器中使用
@Controller('users')
export class UserController {
  @Post()
  async createUser(
    @Body(new I18nValidationPipe()) createUserDto: CreateUserDto,
  ) {
    return this.userService.create(createUserDto);
  }
}

自动化:Swagger/OpenAPI 与校验规则联动

为了让 API 文档与校验规则保持同步,我们可以实现自动化机制:

typescript
// 增强的 DTO,包含 Swagger 注解
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({
    description: 'Username for the user',
    example: 'john_doe',
    maxLength: 50,
  })
  @IsNotEmpty()
  @MaxLength(50)
  username: string;

  @ApiProperty({
    description: 'Email address of the user',
    example: 'john@example.com',
  })
  @IsNotEmpty()
  @IsEmail()
  email: string;

  @ApiProperty({
    description: 'Password for the user account',
    example: 'securePassword123',
    minLength: 8,
    maxLength: 100,
  })
  @IsNotEmpty()
  @MinLength(8)
  @MaxLength(100)
  password: string;

  @ApiProperty({
    description: 'First name of the user',
    example: 'John',
    required: false,
    maxLength: 100,
  })
  @IsOptional()
  @MaxLength(100)
  firstName?: string;

  @ApiProperty({
    description: 'Last name of the user',
    example: 'Doe',
    required: false,
    maxLength: 100,
  })
  @IsOptional()
  @MaxLength(100)
  lastName?: string;
}

// 自定义装饰器,同时支持校验和文档
export function IsStrongPassword(
  options?: { minLength?: number; requireNumbers?: boolean },
): PropertyDecorator {
  return function (target: Object, propertyKey: string | symbol) {
    // 添加校验装饰器
    MinLength(options?.minLength || 8)(target, propertyKey);
    
    // 添加文档装饰器
    ApiProperty({
      minLength: options?.minLength || 8,
      description: 'Strong password with required complexity',
    })(target, propertyKey);
  };
}

// 使用自定义装饰器
export class CreateUserDto {
  @IsNotEmpty()
  @MaxLength(50)
  @ApiProperty({ example: 'john_doe' })
  username: string;

  @IsNotEmpty()
  @IsEmail()
  @ApiProperty({ example: 'john@example.com' })
  email: string;

  @IsStrongPassword({ minLength: 12, requireNumbers: true })
  password: string;
}

文件上传的特殊校验

对于文件上传场景,需要特殊的校验机制:

typescript
// 文件上传 DTO
export class UploadFileDto {
  @IsNotEmpty()
  @ValidateNested()
  @Type(() => FileMetadata)
  file: FileMetadata;
}

class FileMetadata {
  @IsNotEmpty()
  originalName: string;

  @IsNotEmpty()
  mimeType: string;

  @IsPositive()
  size: number;
}

// 文件校验管道
@Injectable()
export class FileValidationPipe implements PipeTransform {
  constructor(
    private readonly configService: ConfigService,
  ) {}

  transform(value: Express.Multer.File | Express.Multer.File[]) {
    const maxFileSize = this.configService.get<number>('MAX_FILE_SIZE', 10 * 1024 * 1024); // 10MB
    const allowedMimeTypes = this.configService.get<string[]>('ALLOWED_MIME_TYPES', [
      'image/jpeg',
      'image/png',
      'application/pdf',
    ]);

    const files = Array.isArray(value) ? value : [value];

    for (const file of files) {
      if (file.size > maxFileSize) {
        throw new BadRequestException(
          `File size exceeds limit of ${maxFileSize} bytes`,
        );
      }

      if (!allowedMimeTypes.includes(file.mimetype)) {
        throw new BadRequestException(
          `File type ${file.mimetype} is not allowed`,
        );
      }
    }

    return value;
  }
}

// 在控制器中使用
@Controller('files')
export class FileController {
  @Post('upload')
  @UseInterceptors(FileInterceptor('file'))
  async uploadFile(
    @UploadedFile(new FileValidationPipe()) file: Express.Multer.File,
  ) {
    // 处理文件上传
  }
}

创业团队行动清单

  1. 立即行动

    • 为所有 API 接口实现基础的 DTO 校验
    • 防止 overposting 攻击,实现字段白名单机制
    • 添加文件上传的特殊校验
  2. 一周内完成

    • 实现友好的错误提示机制
    • 集成 Swagger/OpenAPI 文档与校验规则
    • 添加自定义校验装饰器
  3. 一月内完善

    • 实现多语言错误提示
    • 建立完整的校验规则库
    • 添加校验规则的自动化测试

总结

输入校验是保障系统安全和数据质量的重要机制,正确的实现需要:

  1. 选择合适的校验方案:class-validator、Zod 或其他方案
  2. 防止 overposting:通过白名单机制控制可更新字段
  3. 友好的错误提示:提供清晰、多语言的错误信息
  4. 文档同步:确保 API 文档与校验规则保持一致
  5. 特殊场景处理:针对文件上传等特殊场景实现专门的校验

在下一篇文章中,我们将探讨事务管理,这是保障数据一致性的关键机制。