Whitelist, вложенная валидация и частичные обновления
Закаливание ValidationPipe за пределами базы: whitelist срезает поля без декораторов и убивает mass-assignment, но лишь как один слой; вложенным DTO нужны @ValidateNested + @Type, иначе валидация молча no-op; для PATCH нужен PartialType, а не skipMissingProperties.
DTO регистрации был как из учебника: @IsEmail() на email, @MinLength(8) на password, тесты зелёные, ревью одобрено. Валидация работала идеально — она проверяла ровно те два поля, про которые ей сказали. Через три недели после запуска прилетел тикет: аккаунт с role: 'admin'. Никто его не выдавал. В аудит-логе — обычный POST /auth/register с телом, которого фронтенд никогда не шлёт: { "email": "...", "password": "...", "role": "admin", "isVerified": true }. Сервис делал this.userRepo.save(dto). class-validator увидел два декорированных поля, проверил их и пропустил весь объект целиком — включая два поля, о которых он вообще не слышал. Они уехали прямиком в сохранённую entity. В DTO не было бага. В нём была граница, которая пропускала всё, что ей явно не велели остановить. Этот урок — про закрытие этой границы: whitelist, вложенная валидация и частичные обновления.
whitelist: поле, которого тут быть не должно
По умолчанию ValidationPipe валидирует декорированные свойства и оставляет всё остальное на объекте. Поле без декоратора не отвергается — валидатор его игнорирует и пропускает нетронутым. Это и есть весь механизм инцидента: недекорированный role невидим для валидации, но полностью присутствует в payload, который твой сервис передаёт в ORM.
// main.ts — the hardened global pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip any property with NO validation decorator
forbidNonWhitelisted: true, // …and 400 instead of silently stripping
transform: true, // instantiate the DTO class (also needed for @Type)
}),
);whitelist: true переворачивает дефолт: любое свойство, у которого нет валидационного декоратора на DTO, срезается с объекта до того, как он дойдёт до твоего хендлера. Недекорированные role и isVerified просто перестают существовать. forbidNonWhitelisted: true идёт на шаг дальше — вместо тихого удаления неизвестного поля pipe бросает 400 Bad Request, называя его (property role should not exist). Тихое срезание безопаснее по умолчанию; вариант forbid громче и ловит клиентов (и твой собственный фронтенд), шлющих поля, которые им слать не следует, — а именно это и нужно в сервисе, где неожиданное поле это баг, а не шум.
// RegisterDto — только эти два поля проходят whitelist; role/isVerified срезаются
export class RegisterDto {
@IsEmail()
email: string;
@MinLength(8)
password: string;
// note: NO role, NO isVerified — so whitelist removes them from the body
}Это mass assignment (он же over-posting) — OWASP API3:2023, Broken Object Property Level Authorization. Атакующий не ломает твою валидацию; он эксплуатирует поле, которое твоя валидация никогда не моделировала. whitelist срезает его на входе.
▸Почему это работает
Почему whitelist: true сам по себе недостаточен? Потому что whitelist срезает только поля, у которых нет декоратора. В день, когда кто-то добавит декоратор на role ради не связанного админ-эндпоинта, делящего тот же DTO, — или ты напишешь userRepo.save({ ...dto, createdAt }) и спред заново внесёт подконтрольные атакующему ключи, — граница снова потечёт. whitelist защищает форму DTO, а не его использование. Долговечный фикс — защита в глубину: whitelist, чтобы срезать, плюс никогда не сохраняй сырой DTO. Маппи явные поля в entity (new User(); user.email = dto.email; user.passwordHash = hash(dto.password)), чтобы единственные свойства, способные дойти до базы, были те, что ты прописал руками. save(dto) и { ...dto } — это те две строки, что превращают дыру в валидации в эскалацию привилегий.
Вложенная валидация: декоратор, который ничего не делает
class-validator не рекурсирует во вложенные объекты по умолчанию. Декорируй city внутри AddressDto, вложи этот AddressDto внутрь RegisterDto — и валидация на city молча пропускается. Родитель видит address как непрозрачное значение и никогда внутрь не спускается. Невалидные вложенные данные — city, который число, отсутствующее обязательное поле — проходят насквозь.
import { Type } from 'class-transformer';
import { ValidateNested, IsString, IsArray, ArrayMinSize } from 'class-validator';
class AddressDto {
@IsString() city: string; // ← validated ONLY if the parent opts in below
@IsString() country: string;
}
export class RegisterDto {
@IsEmail() email: string;
@ValidateNested() // recurse into the nested object
@Type(() => AddressDto) // class-transformer must instantiate AddressDto first
address: AddressDto;
@ValidateNested({ each: true }) // recurse into EACH element of the array
@Type(() => RoleDto)
@IsArray()
@ArrayMinSize(1)
roles: RoleDto[];
}Два декоратора заставляют вложенную валидацию реально работать, и нужны оба. @ValidateNested() велит валидатору спуститься. @Type(() => AddressDto) велит class-transformer инстанцировать настоящий AddressDto из простого JSON — без него address остаётся обычным объектом, валидатор не находит метаданных класса для проверки, и вложенные правила становятся no-op. Забыть @Type — классический тихий сбой: ни ошибки, ни предупреждения, валидация просто тихо не происходит. Для массивов объектов добавь { each: true }, чтобы рекурсия применялась к каждому элементу, и пара @IsArray() / @ArrayMinSize() ограничивает сам массив.
Частичные обновления: PUT заменяет, PATCH патчит
PUT заменяет ресурс — ожидается каждое поле. PATCH обновляет некоторые поля, а остальные отсутствуют by design. Переиспользуй create-DTO для PATCH-хендлера — и каждый @IsEmail(), @MinLength() теперь ошибочно требует поле, которое клиент намеренно опустил: частичное обновление падает на валидации за то, что оно частичное.
import { PartialType } from '@nestjs/mapped-types'; // or @nestjs/swagger
// every field of CreateUserDto becomes optional, decorators preserved
export class UpdateUserDto extends PartialType(CreateUserDto) {}
// PATCH /users/:id — body { "email": "new@x.com" } alone now validates
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
return this.users.update(id, dto);
}PartialType(CreateUserDto) строит новый DTO, где каждое унаследованное поле обёрнуто как опциональное (он применяет семантику @IsOptional() поле за полем), сохраняя при этом исходные декораторы для любого поля, которое присутствует. Это хирургично: отсутствующие поля в порядке, присутствующие всё ещё валидируются. Соблазнительный шорткат — new ValidationPipe({ skipMissingProperties: true }) — работает, но это тупой инструмент: он пропускает валидацию для любого отсутствующего свойства во всех DTO приложения, включая обязательные поля, которые ты никогда не хотел делать опциональными, так что по-настоящему-обязательное-но-забытое поле теперь проходит молча. PartialType ограничивает опциональность ровно одним update-DTO. Связанная тонкость: @IsOptional() пропускает валидацию, когда значение null/undefined, — это и делает «поле отсутствует» валидным, — но это также значит, что ты не отличишь «клиент не прислал bio» от «клиент прислал bio: null, чтобы очистить» без отдельного стража @IsDefined()/@ValidateIf.
▸Почему это работает
Зачем маппить поля явно, если whitelist уже срезал role? Потому что whitelist и явный маппинг падают по-разному, так что вместе они прикрывают друг друга. whitelist падает в момент, когда меняется форма DTO, — декоратор, добавленный по не связанной причине, общий DTO, спред { ...dto }, заново вносящий ключи ниже по потоку. Явный маппинг падает… практически никогда, потому что entity получает только те буквальные присваивания, что ты набрал. Цена явного маппинга — несколько строк бойлерплейта на каждый путь create/update. Цена опоры на один whitelist — что один рефакторинг в трёх модулях отсюда молча заново откроет дыру эскалации привилегий. Защита в глубину означает, что ни одно изменение DTO не может стать регрессией безопасности.
Публичный эндпоинт регистрации не должен позволять клиенту назначать себе role (колонка role существует на entity User). Какой подход — самая надёжная защита от mass assignment?
RegisterDto декорирует только email и password. Тело запроса содержит лишнее `role: 'admin'`. Что делает ValidationPipe при whitelist: true против forbidNonWhitelisted: true?
У AddressDto стоит @IsString() на city. Он вложен как `address: AddressDto` внутри RegisterDto без других декораторов на поле. Запрос шлёт `address: { city: 12345 }`. Что происходит?
- 01Объясни уязвимость mass-assignment в эндпоинте регистрации NestJS: как она возникает, что делает whitelist, почему whitelist в одиночку недостаточен и полный фикс.
- 02Как заставить валидацию вложенных объектов реально работать и как корректно поддержать PATCH частичные обновления? Покрой тихие сбои.
Закаливание ValidationPipe за пределами базовых декораторов DTO закрывает три границы. Первое, mass assignment (массовое присвоение — атака через незаявленные поля): по умолчанию pipe валидирует декорированные поля и оставляет недекорированные на объекте, так что DTO регистрации, декорирующий только email и password, пускает атакующий role: ‘admin’ внутрь userRepo.save(dto) — OWASP API3:2023, эскалация привилегий. whitelist: true срезает любое свойство без валидационного декоратора; forbidNonWhitelisted: true даёт 400 вместо тихого срезания. Но whitelist охраняет форму DTO, а не его использование, так что настоящий фикс — защита в глубину: whitelist ПЛЮС явный маппинг полей в entity, никогда save(dto) и не спредить сырой DTO. Второе, вложенная валидация: class-validator не рекурсирует по умолчанию, так что декоратор на поле вложенного DTO — тихий no-op, пока родительское поле не несёт оба — @ValidateNested() (спуститься) и @Type(() => NestedDto) (инстанцировать); массивы объектов добавляют { each: true } плюс @IsArray()/@ArrayMinSize(). Третье, частичные обновления: PUT заменяет, а PATCH патчит, так что переиспользование create-DTO ошибочно требует опущенные поля; UpdateUserDto extends PartialType(CreateUserDto) делает каждое поле опциональным хирургично, а skipMissingProperties: true — тупая глобальная альтернатива, ослабляющая обязательные поля, которые ты не собирался. Валидация, проверяющая только названные тобой поля, — это граница, пропускающая всё, что ты не назвал. Теперь, когда видишь вызов save(dto) или вложенный объект без @ValidateNested, ты знаешь две тихие дыры, которые открыты, — и что именно добавить, чтобы их закрыть.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.