Bỏ qua để đến nội dung
Knowledge Hub

Validation DTO trong NestJS: vì sao role "admin" và địa chỉ sai kiểu vẫn lọt Cơ bản

Thời gian đọc ước tính: 10 phút

API PATCH /users/me cho user sửa displayName, locale và address. Handler nhận @Body() dto: UpdateMeDto rồi lưu xuống database. Code biên dịch không lỗi, test happy path đều xanh.

Một client gửi thêm "role": "admin" và address.street = 42. Request trả 200, role đi tới repository: đây là mass assignment, client ghi được field không được phép sửa (xem Secure coding). Địa chỉ sai kiểu cũng được lưu.

Câu hỏi: vì sao kiểu UpdateMeDto không chặn được gì, và cấu hình ValidationPipe thế nào để cả field lạ lẫn object lồng nhau đều bị kiểm tra?

Tình huống Chọn Điều kiện
Mọi API nhận body ValidationPipe global với whitelist: true Mọi field hợp lệ đều có ít nhất một decorator
Muốn client biết đã gửi field lạ Thêm forbidNonWhitelisted: true Client chấp nhận 400 thay vì bị bỏ qua thầm lặng
DTO có object hoặc mảng lồng nhau @ValidateNested() và @Type(() => X) Mảng thêm { each: true }
Query param cần số transform: true + @Type(() => Number) hoặc ParseIntPipe Tránh enableImplicitConversion cho boolean
Format lỗi riêng (Problem Details) exceptionFactory Mặc định 400; site dùng 422 (xem exception filter)
e2e test phải giống production Khai báo pipe bằng APP_PIPE trong module Thay cho useGlobalPipes chỉ ở main.ts
Khái niệm Ý nghĩa
DTO Class mô tả hình dạng input của một endpoint, gắn decorator validation
plainToInstance Hàm của class-transformer: biến plain object từ JSON thành instance của class
validate Hàm của class-validator: đọc decorator trên instance, trả mảng ValidationError
Pipe Lớp NestJS validate và transform tham số ngay trước handler

Type TypeScript bị xoá khi biên dịch. Lúc runtime, dto: UpdateMeDto chỉ là object do Express parse từ JSON. Thứ còn lại là class UpdateMeDto trong metadata tham số (nhờ emitDecoratorMetadata) và các decorator gắn trên property. Không có decorator thì không có rule nào để kiểm.

ValidationPipe là một pipe, nên chạy sau guard và interceptor (chiều vào), ngay trước handler, như mô tả ở Request lifecycle:

Đang tải diagram…

Hệ quả: guard đọc req.body sẽ thấy dữ liệu chưa qua validation.

Theo source ValidationPipe (@nestjs/common 11.2.6), body có role và address sai kiểu đi qua các bước:

Body có role và address sai kiểu: role bị whitelist bỏ, address bị @ValidateNested chặn, handler không chạy
Body có role và address sai kiểu: role bị whitelist bỏ, address bị @ValidateNested chặn, handler không chạyBước 1/6: Client gửi role: "admin" và address.street = 42. Body là plain object; kiểu UpdateMeDto chỉ còn trong metadata.ValidationPipeJSON bodyPATCH /users/meplainToInstanceUpdateMeDtowhitelistbỏ field lạvalidatekể cả nested400 Bad Requestmessage: string[]updateMe(dto)handler{ role, address }
JSON body → plainToInstance{ role, address }
Bước 1/6

Client gửi role: "admin" và address.street = 42. Body là plain object; kiểu UpdateMeDto chỉ còn trong metadata.

Bước Trước Sau
1. plainToInstance(UpdateMeDto, body) Plain object, address cũng plain Instance UpdateMeDto; address thành AddressDto chỉ khi có @Type
2. validate(entity, options) với whitelist Còn role role bị xoá, hoặc thành lỗi nếu forbidNonWhitelisted
3. Rule từng property, đệ quy vào nested Chưa kiểm Mảng ValidationError
4a. Có lỗi exceptionFactory ném BadRequestException, handler không chạy
4b. Không lỗi transform: true trả instance; false trả plain object
  • whitelist bỏ property không có decorator validation nào. @IsOptional() cũng tính là decorator.
  • forbidNonWhitelisted chỉ có tác dụng khi bật whitelist; lỗi có dạng property role should not exist.
  • Nested: @Type(() => AddressDto) để class-transformer tạo instance; @ValidateNested() để class-validator đi vào instance đó. Mảng dùng @ValidateNested({ each: true }).
  • Query và route param luôn là chuỗi. transform: true chỉ tự đổi kiểu cho tham số đơn có tên, như @Query('active') active: boolean.
  • enableImplicitConversion đổi theo kiểu khai báo của property. Với boolean, chuỗi "false" thành true.
  • Lỗi mặc định: HTTP 400, message là mảng chuỗi; nested có dạng address.zip ..., phần tử mảng là shippingAddresses.1.zip ....
  • PartialType cho DTO của PATCH nằm trong package riêng @nestjs/mapped-types (repo demo chưa cài). Theo docs, nó giữ decorator validation và đánh dấu mọi field optional.

Demo examples/src/nestjs-validation-dto/validation-demo.ts (NestJS 11.2.6, class-validator 0.15.1, class-transformer 0.5.1) dựng app cho từng cấu hình, gửi body bằng fetch, in response thật. DTO:

class AddressDto {
@IsString() @Length(1, 120) street!: string;
@Matches(/^\d{5,6}$/) zip!: string;
}
class UpdateMeDto {
@IsOptional() @IsString() @Length(1, 80) displayName?: string;
@IsOptional() @IsIn(['vi', 'en']) locale?: string;
@IsOptional() @ValidateNested() @Type(() => AddressDto) address?: AddressDto;
@IsOptional() @ArrayMaxSize(3) @ValidateNested({ each: true })
@Type(() => AddressDto) shippingAddresses?: AddressDto[];
}

Body {"displayName":"An","role":"admin"} qua ba cấu hình:

[1] Không có ValidationPipe -> 200 {"displayName":"An","role":"admin"}
[2] new ValidationPipe() -> 200 {"displayName":"An","role":"admin"}
[3] whitelist: true -> 200 {"displayName":"An"}
[4] + forbidNonWhitelisted: true -> 400 {"message":["property role should not exist"],...}

Cấu hình [2] có validate, nhưng role vẫn tới handler vì không ai bảo pipe bỏ field lạ.

Body {"displayName":"An","address":{"street":42,"zip":"abc"}}, kết quả thật:

DTO Cấu hình Response
Đủ @ValidateNested + @Type whitelist 400: address.street must be a string, address.zip must match ...
Thiếu @Type không whitelist 200, address sai kiểu lọt
Thiếu @Type whitelist 200, address: {}: mất dữ liệu thầm lặng
Thiếu @Type whitelist + forbidNonWhitelisted 400 khó hiểu: address.property street should not exist
Thiếu @ValidateNested whitelist + forbidNonWhitelisted 200, address sai kiểu lọt

Thiếu @Type, address vẫn là plain object: không có rule nào của AddressDto, và whitelist xoá mọi key của nó. Thiếu @ValidateNested, validator không đi vào address.

Mảng nested với each: true báo đúng chỉ số phần tử hỏng:

PATCH /users/me {"shippingAddresses":[{"street":"Lê Lợi","zip":"700000"},{"street":"","zip":"1"}]}
-> 400 {"message":["shippingAddresses.1.street must be longer than or equal to 1 characters",
"shippingAddresses.1.zip must match /^\\d{5,6}$/ regular expression"],...}

DTO ListUsersQuery có limit?: number (@IsInt) và active?: boolean (@IsBoolean). Gọi GET /users?limit=20&active=false:

[5] whitelist -> 400 ["...limit must be an integer number","active must be a boolean value"]
[6] + transform: true -> 400 (giống [5])
[7] + transformOptions.enableImplicitConversion: true
-> 200 {"limit":20,"active":true}
Tham số đơn @Query('active') active: boolean, transform: true
-> 200 {"active":false}

transform: true không đổi kiểu property trong DTO query. Implicit conversion đổi được, nhưng "false" thành true, giống Boolean("false"). Trong một thử nghiệm riêng, nó còn đổi street: 42 thành "42" nên @IsString() qua.

Cấu hình ValidationPipe global

Hay gặp

app.useGlobalPipes(new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
}));
// role lọt; ?active=false thành true

Nên viết

app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Query: @Type(() => Number) cho số,
// @Transform(({ value }) => value === 'true') cho boolean

Vì sao

Bản sai bỏ qua field lạ và đoán kiểu theo metadata. Bản đúng chặn field lạ, còn mỗi chuyển kiểu được viết rõ trên property.

exceptionFactory nhận ValidationError[] (có children cho nested) và trả exception. Demo [8] đổi sang danh sách field và rule:

-> 400 {"type":"validation","errors":[{"field":"role","rules":["whitelistValidation"]},
{"field":"address.street","rules":["isLength","isString"]},{"field":"address.zip","rules":["matches"]}]}

Muốn trả Problem Details thì định dạng lỗi ở đây hoặc ở exception filter, xem Problem Details.

useGlobalPipes trong main.ts không chạy trong Test.createTestingModule. Demo [9] gửi role qua app test: 200. Demo [10] khai báo { provide: APP_PIPE, useValue: new ValidationPipe({...}) } trong module: 400, giống production.

Chạy demo:

Terminal window
npx --prefix examples ts-node examples/src/nestjs-validation-dto/validation-demo.ts

Ưu điểm

  • Rule nằm cạnh field, đọc DTO là biết contract của endpoint.

  • whitelist biến DTO thành allowlist, chặn mass assignment ở biên.

  • Lỗi 400 có đường dẫn field (address.zip), client sửa được ngay.

  • < slot="cons">
  • Thiếu một decorator là mất rule mà không có lỗi biên dịch.

  • plainToInstance và validate tốn CPU với body lớn, mảng dài.

  • Chỉ kiểm hình dạng; quyền sửa field và ràng buộc nghiệp vụ vẫn thuộc service.

Nhược điểm

  1. Đặt ValidationPipe với whitelist: true, forbidNonWhitelisted: true bằng APP_PIPE để test và production dùng chung.
  2. Mỗi property hợp lệ có ít nhất một decorator; field optional dùng @IsOptional().
  3. Mọi object lồng nhau có cặp @ValidateNested() + @Type(); mảng thêm each: true và @ArrayMaxSize.
  4. Giới hạn độ dài chuỗi và kích thước mảng; body hợp lệ về kiểu vẫn có thể quá lớn.
  5. Không bật enableImplicitConversion toàn cục khi DTO có boolean; chuyển kiểu query bằng @Type hoặc @Transform.
  6. Tách DTO cho user và admin; không dùng entity ORM làm DTO.
  7. Viết e2e test gửi field lạ và nested sai kiểu, kỳ vọng 400.
Cách hiểu sai Hệ quả Cần làm
Kiểu TypeScript của tham số là validation Mọi JSON đều lọt vào handler Gắn decorator và bật ValidationPipe
new ValidationPipe() đã chặn field lạ role: "admin" tới repository Bật whitelist
@ValidateNested() là đủ cho nested Nested bị bỏ qua hoặc bị xoá rỗng Thêm @Type(() => X)
Pipe global ở main.ts cũng chạy trong e2e test Test xanh, production khác APP_PIPE hoặc gọi useGlobalPipes trong setup test
  1. Vì sao new ValidationPipe() không option trả 400 cho address sai kiểu nhưng vẫn để role lọt?
  2. DTO thiếu @Type trên address, bật whitelist: handler nhận address là gì, và vì sao?
  3. Vì sao ?active=false thành true khi bật enableImplicitConversion, còn @Query('active') active: boolean thì không?
  4. e2e test dùng Test.createTestingModule trả 200 cho body có role. Sửa ở đâu để test phản ánh production?