Пошук уроків, статей та іншого контенту
Захистите контролери за допомогою ValidationPipe, DTO, whitelist і правил перевірки користувацьких даних.
Вхідні дані від клієнта не можна вважати надійними. Користувач може надіслати:
відсутні обов’язкові поля;
неправильні типи даних;
занадто довгі або короткі значення;
невідомі поля;
рядки з випадковими пробілами;
значення, які не відповідають бізнес-правилам.
У NestJS для перевірки вхідних даних зазвичай використовують:
DTO — опис очікуваної структури даних;
class-validator — набір правил перевірки;
class-transformer — перетворення та нормалізація значень;
ValidationPipe — pipe, який запускає перевірку перед викликом методу контролера.
Валідація відповідає на запитання: «Чи коректне це значення?».
Санітизація змінює або видаляє дані, щоб привести їх до безпечного та очікуваного вигляду. Наприклад:
прибрати пробіли на початку та в кінці рядка;
перетворити email на нижній регістр;
видалити невідомі властивості;
перетворити рядок із query-параметрів на число.
DTO (Data Transfer Object) — це клас, який описує формат даних, що надходять до контролера.
Для роботи потрібно встановити пакети:
npm install class-validator class-transformerПриклад DTO для створення користувача:
// src/users/dto/create-user.dto.ts
import { Transform } from 'class-transformer';
import {
IsEmail,
IsNotEmpty,
IsString,
Length,
Matches,
MaxLength,
} from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@Length(2, 30)
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
username: string;
@IsEmail()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
email: string;
@IsString()
@Length(8, 64)
@Matches(/[A-Z]/, {
message: 'Пароль повинен містити хоча б одну велику літеру',
})
@Matches(/[0-9]/, {
message: 'Пароль повинен містити хоча б одну цифру',
})
password: string;
}У цьому DTO:
@IsString() перевіряє, що значення є рядком;
@IsNotEmpty() забороняє порожнє значення;
@Length(2, 30) обмежує довжину рядка;
@IsEmail() перевіряє формат email;
@Matches() перевіряє відповідність регулярному виразу;
@Transform() нормалізує значення перед валідацією.
Пароль навмисно не очищується через trim(). Пробіл може бути частиною пароля, тому автоматична зміна пароля під час санітизації може призвести до неочікуваної поведінки.
ValidationPipe можна підключити глобально у файлі main.ts. Тоді він застосовуватиметься до всіх контролерів застосунку.
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(3000);
}
bootstrap();transformПараметр transform: true перетворює звичайний об’єкт запиту на екземпляр DTO-класу.
Без цього параметра декоратори class-transformer не виконуватимуть перетворення вхідних значень. Також він дає змогу використовувати типи DTO під час обробки запиту.
whitelistПараметр whitelist: true видаляє властивості, які не мають декораторів валідації у DTO.
Наприклад, якщо клієнт надішле:
{
"username": "olena",
"email": "olena@example.com",
"password": "StrongPass1",
"role": "admin"
}Властивість role не описана в CreateUserDto, тому при whitelist: true вона буде видалена.
Важливо: властивість вважається дозволеною, якщо в її описі є декоратор class-validator. Тому навіть для поля без складних правил варто додати відповідний декоратор, наприклад @IsString().
forbidNonWhitelistedПараметр forbidNonWhitelisted: true не видаляє невідомі властивості мовчки, а повертає помилку 400 Bad Request.
Це корисно, коли клієнт повинен чітко знати, що надіслав непідтримуване поле.
Якщо потрібна більш поблажлива поведінка, можна залишити лише:
new ValidationPipe({
transform: true,
whitelist: true,
});У такому випадку зайві властивості будуть видалені без помилки.
// src/users/users.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
@Post()
create(@Body() createUserDto: CreateUserDto) {
return {
message: 'Користувача прийнято',
data: createUserDto,
};
}
}Тепер запит:
POST /users
Content-Type: application/jsonз тілом:
{
"username": " olena ",
"email": " OLENA@EXAMPLE.COM ",
"password": "StrongPass1"
}після трансформації матиме приблизно такий вигляд:
{
"username": "olena",
"email": "olena@example.com",
"password": "StrongPass1"
}Якщо надіслати неправильні дані:
{
"username": "o",
"email": "not-an-email",
"password": "weak"
}ValidationPipe не передасть запит у метод create(), а поверне відповідь із кодом 400.
Для необов’язкового поля використовується @IsOptional().
// src/users/dto/update-user.dto.ts
import { Transform } from 'class-transformer';
import { IsEmail, IsOptional, IsString, Length } from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
@Length(2, 30)
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
username?: string;
@IsOptional()
@IsEmail()
@Transform(({ value }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
email?: string;
}@IsOptional() пропускає null і undefined, але якщо значення передано, інші декоратори все одно виконуються.
Наприклад:
відсутній email — допустимо;
email: "user@example.com" — допустимо;
email: "invalid" — помилка.
ValidationPipe може перевіряти не лише тіло запиту, а й параметри маршруту або query-параметри.
Створимо DTO для параметрів пагінації:
// src/users/dto/list-users-query.dto.ts
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListUsersQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
}Query-параметри надходять у HTTP як рядки. Наприклад, у запиті:
GET /users?page=2&limit=10значення page і limit спочатку є рядками "2" та "10".
@Type(() => Number) перетворює їх на числа, а @IsInt(), @Min() і @Max() перевіряють результат.
Контролер із цим DTO:
// src/users/users.controller.ts
import {
Body,
Controller,
Get,
Post,
Query,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
@Controller('users')
export class UsersController {
@Get()
findAll(@Query() query: ListUsersQueryDto) {
return {
page: query.page,
limit: query.limit,
};
}
@Post()
create(@Body() createUserDto: CreateUserDto) {
return {
message: 'Користувача прийнято',
data: createUserDto,
};
}
}Не варто покладатися лише на тип TypeScript:
findAll(@Query() query: { page: number }) {
// У HTTP query-параметр page фактично є рядком
}Типи TypeScript видаляються під час компіляції та самі по собі не виконують перевірку в runtime. Для перевірки потрібні DTO-клас і декоратори.
Якщо поле може містити лише кілька визначених значень, використовуйте @IsEnum().
import { IsEnum, IsOptional } from 'class-validator';
enum UserStatus {
ACTIVE = 'active',
BLOCKED = 'blocked',
}
export class UpdateUserStatusDto {
@IsEnum(UserStatus)
status: UserStatus;
@IsOptional()
reason?: string;
}Тепер значення "active" і "blocked" будуть дозволені, а будь-яке інше значення спричинить помилку валідації.
Для валідації вкладеного DTO потрібно вказати:
@ValidateNested();
@Type(() => NestedDto).
import { Type } from 'class-transformer';
import {
IsString,
ValidateNested,
} from 'class-validator';
class AddressDto {
@IsString()
city: string;
@IsString()
street: string;
}
export class CreateProfileDto {
@IsString()
name: string;
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
}Без @Type(() => AddressDto) вкладений об’єкт не буде коректно перетворений на екземпляр класу AddressDto, а без @ValidateNested() його правила можуть не виконатися.
Санітизація не замінює валідацію.
Наприклад, обрізання пробілів не перетворює неправильний email на правильний:
@Transform(({ value }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsEmail()
email: string;Тут виконуються дві різні дії:
@Transform() прибирає зайві пробіли;
@IsEmail() перевіряє формат результату.
whitelist також є формою санітизації: він прибирає властивості, яких немає в DTO. Проте він не очищує довільний текст від усіх потенційно небезпечних символів і не замінює правильне екранування під час роботи з базою даних або HTML.
Правила мають відповідати призначенню поля:
email можна привести до нижнього регістру;
ім’я або назву можна обрізати по краях;
пароль не варто автоматично змінювати;
числові параметри потрібно перетворити та перевірити діапазон;
невідомі поля потрібно відкидати або відхиляти.
Декоратори можуть містити власні повідомлення:
import { IsString, Length } from 'class-validator';
export class CreateCategoryDto {
@IsString({ message: 'Назва повинна бути рядком' })
@Length(2, 50, {
message: 'Назва повинна містити від 2 до 50 символів',
})
name: string;
}Це дає змогу повертати клієнту зрозуміліші повідомлення. Для публічного API також варто враховувати, яку інформацію про внутрішню структуру застосунку можна розкривати у відповідях.
Таке оголошення не забезпечує runtime-валідацію:
interface CreateUserRequest {
email: string;
}interface існує лише на етапі компіляції. Для ValidationPipe потрібен клас із декораторами.
DTO із декораторами сам по собі не запускає валідацію. Потрібно підключити ValidationPipe:
app.useGlobalPipes(new ValidationPipe());Якщо поле має бути дозволеним, але на ньому немає декоратора class-validator, whitelist: true може його видалити.
export class CreateDto {
// Це поле може бути видалене whitelist
description: string;
}Краще додати хоча б базове правило:
import { IsOptional, IsString } from 'class-validator';
export class CreateDto {
@IsOptional()
@IsString()
description?: string;
}Значення з URL надходять як рядки. Для перетворення використовуйте @Type(() => Number) і перевіряйте результат через @IsInt(), @Min() та інші декоратори.
@IsOptional() до обов’язкового поляЯкщо поле необхідне для створення ресурсу, не додавайте @IsOptional(). Інакше відсутність поля не спричинить помилку.
Автоматичне обрізання пробілів у паролі змінює секретне значення. Якщо бізнес-правила не забороняють пробіли явно, не застосовуйте до пароля таку трансформацію.
DTO описує очікувану структуру вхідних даних.
class-validator перевіряє значення за допомогою декораторів.
ValidationPipe запускає валідацію до виконання методу контролера.
transform: true вмикає перетворення даних і роботу class-transformer.
whitelist: true видаляє властивості, яких немає в DTO.
forbidNonWhitelisted: true відхиляє запити з невідомими властивостями.
@Transform() використовується для контрольованої нормалізації даних.
Для query-параметрів потрібно враховувати, що спочатку вони є рядками.
Санітизація доповнює валідацію, але не замінює її.