第 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,
) {
// 处理文件上传
}
}创业团队行动清单
立即行动:
- 为所有 API 接口实现基础的 DTO 校验
- 防止 overposting 攻击,实现字段白名单机制
- 添加文件上传的特殊校验
一周内完成:
- 实现友好的错误提示机制
- 集成 Swagger/OpenAPI 文档与校验规则
- 添加自定义校验装饰器
一月内完善:
- 实现多语言错误提示
- 建立完整的校验规则库
- 添加校验规则的自动化测试
总结
输入校验是保障系统安全和数据质量的重要机制,正确的实现需要:
- 选择合适的校验方案:class-validator、Zod 或其他方案
- 防止 overposting:通过白名单机制控制可更新字段
- 友好的错误提示:提供清晰、多语言的错误信息
- 文档同步:确保 API 文档与校验规则保持一致
- 特殊场景处理:针对文件上传等特殊场景实现专门的校验
在下一篇文章中,我们将探讨事务管理,这是保障数据一致性的关键机制。