Input Validation là “chân ái”: Zod và class-validator trong NestJS
Hướng dẫn input validation trong NestJS bằng Zod và class-validator: ValidationPipe, DTO, strict schemas, transform/coercion, nested objects, mass assignment và checklist production.
ShanverseUpdated 11 min read— viewsLoading views
Input Validation
NestJS
API Security
Vì sao TypeScript interface không đủ?
Đoạn code này tạo cảm giác an toàn giả:
Client vẫn có thể gửi:
Type annotation không biến payload thành đúng kiểu và không xóa role. Interface biến mất sau khi transpile. Validation phải chạy ở runtime, ngay tại boundary, trước business logic, database và external calls.
Input nào cần validate?
Không chỉ req.body:
Path params và query string.
Headers, cookies và JWT claims.
Comments
Multipart fields, filename, MIME type và file size.
Webhook payloads và signatures.
Messages từ Kafka/RabbitMQ/SQS.
Cache/database records từ schema cũ.
Environment variables và config.
Dữ liệu từ internal service khác.
“Internal” không đồng nghĩa “trusted”. Một service bị compromise hoặc deploy lệch version vẫn có thể gửi payload sai.
Validation, parsing, normalization và sanitization
Các khái niệm này không giống nhau:
Parsing: biến bytes/string thành cấu trúc dữ liệu.
Validation cú pháp: đúng type, format, length, enum, shape.
Normalization: đưa các dạng tương đương về canonical form, ví dụ trim email, Unicode normalization.
Sanitization: biến đổi dữ liệu để an toàn cho một sink cụ thể.
Authorization: kiểm tra actor có quyền thực hiện hành động hay không.
Validation không thay thế parameterized SQL, output encoding chống XSS, authorization hoặc CSRF protection. Nó giảm attack surface, không phải “thuốc chữa mọi lỗ hổng”.
Chiến lược mặc định: allowlist schema
Một schema tốt mô tả chính xác dữ liệu được phép:
Type rõ ràng.
Min/max cho string, number, array và nesting depth.
Enum thay vì arbitrary string.
Unknown keys bị từ chối hoặc strip có chủ đích.
Cross-field rules.
Request size và content type.
Error contract ổn định.
Denylist như “cấm <script>” dễ bị bypass và không chứng minh input hợp lệ. Hãy mô tả dữ liệu được phép, không cố liệt kê vô hạn dữ liệu xấu.
Lựa chọn 1: class-validator + ValidationPipe
Cách NestJS native nhất là DTO class với decorators.
Bật global pipe:
Ý nghĩa:
transform: true: biến plain object thành DTO instance và transform params khi cấu hình phù hợp.
whitelist: true: chỉ giữ property có validation metadata.
forbidNonWhitelisted: true: thay vì im lặng strip field lạ, trả lỗi—hữu ích cho public/write APIs.
forbidUnknownValues: true: từ chối object không có metadata phù hợp.
Không trả target/value: tránh echo dữ liệu nhạy cảm trong error.
DTO phải là class, không phải interface
Nest cần runtime metadata. Interface và type alias biến mất sau compile, nên ValidationPipe không thể biết constraint.
Dùng class DTO và bảo đảm compiler/decorator metadata được cấu hình theo yêu cầu phiên bản/framework.
Nested object và array
Nested validation cần khai báo đầy đủ:
Thiếu @ValidateNested hoặc @Type có thể làm nested object không được transform/validate như dự kiến. Luôn test payload chứa field lạ ở nhiều cấp.
Coercion: tiện nhưng dễ gây bất ngờ
Query và path params đến dưới dạng string. Có thể transform rõ ràng:
Tránh bật implicit conversion rộng rồi tin rằng mọi input trở nên hợp lệ. Boolean đặc biệt nguy hiểm: trong JavaScript, Boolean('false') là true.
Tốt hơn là parse có chủ đích:
Transform không thay validation. Sau coercion vẫn phải kiểm tra finite number, range, integer và domain rules.
Optional không đồng nghĩa nullable
Trong PATCH DTO:
Missing: không cập nhật.
undefined: thường tương đương missing nhưng phải thống nhất serializer.
null: có thể là yêu cầu xóa giá trị.
Empty string: có thể là dữ liệu hợp lệ hoặc không.
Thiết kế contract rõ ràng. @IsOptional() thường bỏ qua validator khi giá trị vắng/nullable theo behavior thư viện; đừng vô tình cho phép null nếu domain không chấp nhận. Viết test cho cả bốn trường hợp.
Validation groups và DTO riêng
Một DTO dùng chung cho create, update, admin và public dễ trở thành ma trận decorator khó hiểu. Ưu tiên DTO/schema riêng theo use case:
Điều này cũng giảm mass assignment: endpoint public không bao giờ có field role, isAdmin, creditLimit trong input contract.
Lựa chọn 2: Zod schema-first
Zod định nghĩa runtime schema và suy ra static type từ cùng một nguồn.
z.strictObject() từ chối unknown keys. Đây thường là lựa chọn tốt cho write endpoint vì client biết ngay contract mismatch. Nếu muốn backward compatibility, có thể strip field lạ có chủ đích, nhưng phải hiểu rủi ro typo và mass assignment.
Parse:
Chỉ dùng result.data sau parse. Không validate rồi tiếp tục truyền payload gốc xuống service.
Tạo ZodPipe trong NestJS
Dùng tại controller:
Có thể xây decorator/factory để giảm boilerplate, nhưng đừng ẩn schema đến mức audit route khó khăn.
Coercion trong Zod
Query schema có thể dùng preprocessing có kiểm soát:
Cẩn thận với coercion rộng như z.coerce.boolean(): nó dựa trên truthiness JavaScript, nên chuỗi 'false' có thể không cho kết quả người dùng mong đợi. Hãy định nghĩa parser cho wire format thực tế.
Cross-field và discriminated union
Với nhiều biến thể payload, discriminated union rõ hơn object có hàng loạt optional fields:
Async validation và database constraint
Kiểm tra “email chưa tồn tại” trong validator là UX check, không phải guarantee. Hai request đồng thời vẫn có thể cùng pass rồi insert.
Đúng hơn:
Validate shape/format ở boundary.
Kiểm tra business rule trong application/domain service.
Dùng unique constraint trong database làm nguồn bảo đảm cuối.
Map constraint violation thành lỗi domain/API ổn định.
Không nhét network call hoặc query chậm vào mọi field validator nếu điều đó làm latency, retry và observability khó kiểm soát.
class-validator hay Zod?
Chọn một convention chính cho codebase. Dùng cả hai ngẫu nhiên khiến error shape, unknown-key behavior và coercion không nhất quán.
Validation ở đâu trong kiến trúc?
Mermaid
Loading diagram…
Boundary validation bảo vệ shape.
Domain validation bảo vệ invariant.
Authorization bảo vệ quyền truy cập.
Database constraints bảo vệ consistency cuối cùng.
Đừng để ORM entity trở thành public DTO. Entity thường có field nội bộ không nên nhận từ client.
Error contract tốt
Ví dụ:
Nguyên tắc:
Path machine-readable.
Code ổn định hơn message.
Không echo password, token, file content hoặc toàn bộ payload.
Không lộ stack trace, schema internals hay regex nhạy cảm.
Có localization ở presentation layer nếu cần.
Log validation failures theo sampling/rate limit để không tạo log DoS.
Bảo vệ khỏi resource-exhaustion
Schema validation cũng có thể bị abuse:
Body hàng trăm MB.
Array hàng triệu phần tử.
Object lồng quá sâu.
Regex catastrophic backtracking.
Async refine gọi database cho từng item.
Union quá lớn gây parse tốn CPU.
Đặt giới hạn trước và trong schema:
Reverse proxy/body parser limit.
Content-Type allowlist.
Max string/array/object depth.
File count/size.
Timeout và concurrency limit.
Regex an toàn, bounded input.
Từ chối payload quá lớn bằng 413 Payload Too Large trước khi parse đầy đủ nếu có thể.
Testing chiến lược
Mỗi schema/DTO cần test:
Happy path và boundary min/max.
Missing, null, empty string và wrong type.
Unknown fields ở root và nested level.
Unicode, whitespace và normalization.
Number overflow, NaN, Infinity, scientific notation.
Date/timezone và invalid calendar date.
Huge arrays, deep objects và long strings.
Prototype-looking keys.
PATCH semantics.
Error mapping không lộ secret.
Dùng property-based/fuzz testing cho parser quan trọng. Contract tests bảo đảm client/server/OpenAPI không drift.
Những lỗi thường gặp
Chỉ thêm TypeScript type: không có runtime effect.
Validate quá muộn: DB/external API đã nhận dữ liệu xấu.
Tin transform quá mức: coercion tạo giá trị bất ngờ.
Không chặn unknown fields: typo và mass assignment lọt qua.
Dùng ORM entity làm DTO: expose field nội bộ.
Chỉ validate body: query/params/header/message bị bỏ quên.
Gộp create/update/admin DTO: contract khó audit.
Dùng regex thay parser chuẩn: email/URL/date edge cases sai.
Kiểm tra uniqueness chỉ ở validator: race condition.
Trả lỗi quá chi tiết: leak payload hoặc implementation.
Checklist production
Mọi external input bắt đầu là unknown.
Validation chạy tại controller/consumer boundary.
Có allowlist schema và policy unknown keys rõ ràng.
Body size, content type, array length và nesting depth được giới hạn.
Coercion explicit, đặc biệt với boolean/number/date.
Nested DTO/schema được validate đến mọi cấp.
Create, update, admin và public dùng contract riêng.
Service chỉ nhận parsed data, không nhận raw payload.
Semantic rules và authorization không bị trộn với syntax validation.
Database vẫn có unique/check/foreign-key constraints.
Error code/path ổn định và không echo secret.
Có tests cho unknown fields, boundary, fuzz và resource exhaustion.
Validation failures được quan sát nhưng có sampling/rate limit.
Kết luận
Input validation là “chân ái” vì nó biến trust boundary từ giả định thành contract có thể thực thi. Nhưng hiệu quả chỉ xuất hiện khi schema strict, coercion có chủ đích, nested data được kiểm tra, field lạ bị xử lý rõ ràng và business invariant vẫn có lớp bảo vệ riêng.
Chọn class-validator nếu codebase NestJS thiên về DTO class và decorators. Chọn Zod nếu muốn schema-first, type inference và composition mạnh. Dù chọn công cụ nào, nguyên tắc không đổi: parse một lần ở boundary, chỉ truyền dữ liệu đã validate vào core, và không bao giờ nhầm compile-time type với runtime trust.
Hướng dẫn tích hợp cổng thanh toán VNPay, ZaloPay, MoMo và Stripe: payment flow, chữ ký webhook/IPN, idempotency, retry, reconciliation, refund và vận hành production.
Hướng dẫn kiến trúc Open Banking và Open API Spec: FAPI 2.0, OAuth 2.0/OIDC, PAR, PKCE, mTLS/DPoP, consent management, TPP onboarding, API security và vận hành.
Hướng dẫn tích hợp VietQR và QR Code động với webhook/IPN từ ngân hàng hoặc cổng thanh toán: xác minh chữ ký, chống replay, matching giao dịch, idempotency và reconciliation.