How to Properly Sanitize Input in NestJS: Essential Steps for Secure DTOs Beyond Validation
In modern web development, securing user input is non-negotiable. Malicious actors often exploit unsanitized data to launch attacks like SQL injection, cross-site scripting (XSS), or data corruption. While validation ensures data meets expected formats (e.g., "email must be valid"), sanitization goes further: it cleans, transforms, or modifies input to eliminate risks before it reaches your application’s core.
NestJS, a popular Node.js framework, leverages Data Transfer Objects (DTOs) to manage input/output. But many developers stop at validation (using class-validator), missing critical sanitization steps. This blog will guide you through sanitizing DTOs in NestJS, ensuring your application is resilient against common vulnerabilities.
Table of Contents#
- Understanding DTOs: The Foundation of Input Handling
- Validation vs. Sanitization: What’s the Difference?
- Essential Tools:
class-validatorandclass-transformer - Step 1: Setting Up the Validation Pipe
- Step 2: Whitelisting and Stripping Unknown Properties
- Step 3: Built-in Sanitization with
class-transformerDecorators - Step 4: Custom Sanitization with
@Transform - Step 5: Sanitizing Nested Objects and Arrays
- Step 6: Sanitizing Specific Data Types
- Common Pitfalls to Avoid
- Real-World Example: Sanitizing a User Registration DTO
- Advanced: Custom Sanitization Pipes
- Testing Input Sanitization
- Conclusion: Building Secure DTOs
- References
1. Understanding DTOs: The Foundation of Input Handling#
DTOs (Data Transfer Objects) are classes that define the structure of data passed between layers of your application (e.g., from the client to the API). In NestJS, DTOs act as a contract for input validation and sanitization, ensuring only expected, safe data enters your system.
Example of a basic DTO:
// user.dto.ts
export class CreateUserDto {
email: string;
username: string;
age: number;
}Without sanitization, this DTO would accept raw, unprocessed input—leaving your app vulnerable to attacks like XSS (if username contains HTML) or data inconsistency (e.g., email with accidental uppercase letters).
2. Validation vs. Sanitization: What’s the Difference?#
- Validation: Checks if input meets rules (e.g., "email must be a valid format," "age must be ≥ 18"). It answers: "Is this data valid?"
- Sanitization: Modifies input to make it safe and consistent (e.g., trimming whitespace, escaping HTML, converting
emailto lowercase). It answers: "Is this data safe to use?"
Why both matter: Even valid data can be unsafe. For example, a valid email like [email protected] is technically correct but inconsistent—sanitizing it to [email protected] prevents duplicates. Similarly, a valid username with HTML (<script>...</script>) is dangerous without sanitization.
3. Essential Tools: class-validator and class-transformer#
NestJS relies on two libraries for DTO handling:
class-validator: Provides decorators for validation (e.g.,@IsEmail(),@Min(18)).class-transformer: Transforms raw input (e.g., JSON from HTTP requests) into class instances, enabling sanitization via decorators like@Transform.
Install them first:
npm install class-validator class-transformer4. Step 1: Setting Up the Validation Pipe#
NestJS uses pipes to process input data. The ValidationPipe (built into NestJS) integrates class-validator and class-transformer to validate and sanitize DTOs globally.
Configure it in main.ts to apply sanitization across your app:
// main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global validation/sanitization pipe
app.useGlobalPipes(
new ValidationPipe({
transform: true, // Enable class-transformer to transform input into DTO instances
transformOptions: {
enableImplicitConversion: true, // Auto-convert types (e.g., string "18" → number 18)
},
}),
);
await app.listen(3000);
}
bootstrap();transform: true: Ensures raw input (e.g., JSON) is converted into a DTO class instance, making sanitization decorators work.enableImplicitConversion: Safely converts strings to numbers/booleans where needed (e.g., query params like?age=18→number 18).
5. Step 2: Whitelisting and Stripping Unknown Properties#
Attackers often send extra, unexpected fields (e.g., isAdmin: true) to exploit vulnerabilities. Whitelisting ensures only properties defined in the DTO are allowed.
Update the ValidationPipe to enable whitelisting:
new ValidationPipe({
whitelist: true, // Strip properties not defined in the DTO
forbidNonWhitelisted: true, // Throw an error if unknown properties are present (optional but strict)
transform: true,
})Example: If a client sends { email: "[email protected]", isAdmin: true } to a DTO with only email, isAdmin will be stripped (whitelisting) or trigger an error (if forbidNonWhitelisted: true).
6. Step 3: Built-in Sanitization with class-transformer Decorators#
class-transformer provides decorators to sanitize input without writing custom logic. Here are the most useful ones:
Trimming Strings#
Use @Transform to remove leading/trailing whitespace from strings:
import { Transform } from 'class-transformer';
export class CreateUserDto {
@Transform(({ value }) => value?.trim()) // Trim whitespace
username: string;
}Converting Data Types#
Force input into a specific type (e.g., string → number):
import { Transform } from 'class-transformer';
export class CreateProductDto {
@Transform(({ value }) => Number(value)) // Convert input to number
price: number;
}Escaping HTML#
Prevent XSS attacks by escaping HTML in user-generated content (e.g., bios, comments):
import { Transform } from 'class-transformer';
import { escape } from 'html-escaper'; // Install with: npm install html-escaper
export class CreatePostDto {
@Transform(({ value }) => escape(value)) // Escape HTML tags
content: string;
}Lowercasing/Uppercasing Strings#
Normalize strings (e.g., emails, usernames) to avoid duplicates:
import { Transform } from 'class-transformer';
export class CreateUserDto {
@Transform(({ value }) => value?.toLowerCase()) // Convert email to lowercase
email: string;
}7. Step 4: Custom Sanitization with @Transform#
For complex sanitization (e.g., clamping numbers, formatting phone numbers), use @Transform with a custom function.
Example: Clamping a Number#
Ensure age is between 18 and 120:
import { Transform } from 'class-transformer';
export class CreateUserDto {
@Transform(({ value }) => {
const num = Number(value);
return Math.min(Math.max(num, 18), 120); // Clamp between 18 and 120
})
age: number;
}Example: Formatting Phone Numbers#
Normalize phone numbers to +1234567890 format:
import { Transform } from 'class-transformer';
export class CreateContactDto {
@Transform(({ value }) => {
// Remove non-digit characters and prepend country code
const cleaned = value.replace(/\D/g, '');
return `+${cleaned}`;
})
phone: string;
}8. Step 5: Sanitizing Nested Objects and Arrays#
Nested objects (e.g., addresses) or arrays (e.g., list of tags) require sanitization too. Define nested DTOs and apply sanitization decorators there.
Example: Nested Address DTO#
// address.dto.ts
import { Transform } from 'class-transformer';
export class AddressDto {
@Transform(({ value }) => value?.trim())
street: string;
@Transform(({ value }) => value?.toUpperCase())
city: string;
}
// user.dto.ts
import { Type } from 'class-transformer';
import { AddressDto } from './address.dto';
export class CreateUserDto {
// ... other fields ...
@Type(() => AddressDto) // Transform nested object to AddressDto instance
address: AddressDto;
}Example: Sanitizing Arrays#
Sanitize each element in an array (e.g., tags):
import { Transform, Type } from 'class-transformer';
export class CreatePostDto {
@Type(() => String) // Ensure array elements are strings
@Transform(({ value }) =>
value.map((tag: string) => tag.trim().toLowerCase()) // Trim and lowercase each tag
)
tags: string[];
}9. Step 6: Sanitizing Specific Data Types#
Different data types require unique sanitization logic. Here’s how to handle common types:
Strings#
- Trim whitespace.
- Escape HTML (for user-generated content).
- Normalize case (e.g., emails to lowercase).
Numbers#
- Clamp to min/max values (e.g.,
age ≥ 18). - Round to fixed decimals (e.g.,
priceto 2 decimals).
Dates#
- Parse raw strings (e.g.,
"2024-01-01") intoDateobjects. - Validate date ranges (e.g., "birthdate must be in the past").
Booleans#
- Convert "true"/"false" strings to actual booleans:
@Transform(({ value }) => value === 'true' || value === true) isActive: boolean;
10. Common Pitfalls to Avoid#
- Forgetting
transform: true: Withouttransform: trueinValidationPipe,class-transformerwon’t process decorators like@Transform. - Sanitizing After Validation: Sanitize before validation (e.g., lowercase an email before checking if it’s valid with
@IsEmail()). - Ignoring Nested Objects: Always use
@Type(() => NestedDto)for nested objects—otherwise, sanitization decorators in nested DTOs won’t run. - Over-Sanitizing: Avoid stripping valid characters (e.g., hyphens in phone numbers) unless necessary.
11. Real-World Example: Sanitizing a User Registration DTO#
Let’s combine all steps into a RegisterUserDto that sanitizes email, username, bio, age, and address:
// register-user.dto.ts
import { IsEmail, IsString, MinLength, MaxLength, Min } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { escape } from 'html-escaper';
import { AddressDto } from './address.dto';
export class RegisterUserDto {
@Transform(({ value }) => value?.trim().toLowerCase()) // Trim + lowercase
@IsEmail() // Validate after sanitization
email: string;
@Transform(({ value }) => value?.trim()) // Trim whitespace
@IsString()
@MinLength(3)
@MaxLength(20)
username: string;
@Transform(({ value }) => escape(value)) // Escape HTML
@IsString()
bio: string;
@Transform(({ value }) => {
const age = Number(value);
return Math.min(Math.max(age, 18), 120); // Clamp between 18-120
})
@Min(18) // Validate after clamping
age: number;
@Type(() => AddressDto) // Sanitize nested address
address: AddressDto;
}12. Advanced: Custom Sanitization Pipes#
For reusable sanitization logic (e.g., across multiple DTOs), create a custom pipe.
Example: TrimPipe for All Strings#
// trim.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
@Injectable()
export class TrimPipe implements PipeTransform {
transform(value: any, { metatype }: ArgumentMetadata) {
if (!metatype || !this.isDto(metatype)) {
return value; // Skip if not a DTO
}
// Convert value to DTO instance and trim all string properties
const instance = plainToInstance(metatype, value);
for (const key in instance) {
if (typeof instance[key] === 'string') {
instance[key] = instance[key].trim();
}
}
return instance;
}
private isDto(metatype: Function): boolean {
return metatype === String || metatype === Number ? false : true;
}
}Use it in a controller:
@Post('register')
@UsePipes(new TrimPipe()) // Apply TrimPipe to this endpoint
register(@Body() dto: RegisterUserDto) {
// dto.username is now trimmed!
}13. Testing Input Sanitization#
Ensure sanitization works with unit tests. Use Jest to verify transformed output.
Example Test for RegisterUserDto#
// register-user.dto.spec.ts
import { plainToInstance } from 'class-transformer';
import { RegisterUserDto } from './register-user.dto';
describe('RegisterUserDto', () => {
it('should sanitize email to lowercase and trim', () => {
const rawInput = { email: ' [email protected] ' };
const dto = plainToInstance(RegisterUserDto, rawInput);
expect(dto.email).toBe('[email protected]');
});
it('should escape HTML in bio', () => {
const rawInput = { bio: '<script>alert("xss")</script>' };
const dto = plainToInstance(RegisterUserDto, rawInput);
expect(dto.bio).toBe('<script>alert("xss")</script>');
});
});14. Conclusion: Building Secure DTOs#
Input sanitization is a critical layer of defense in NestJS applications. By combining class-validator (validation) and class-transformer (sanitization), you ensure data is both valid and safe. Remember to:
- Use
ValidationPipewithtransform: trueandwhitelist: true. - Sanitize early (before validation).
- Handle nested objects with
@Type. - Test sanitization logic to catch regressions.
With these steps, you’ll build DTOs that protect your app from data leaks, XSS, and inconsistencies.