open atlas
↑ К треку
NestJS с нуля до senior NEST · 02 · 04

Whitelist, вложенная валидация и частичные обновления

Закаливание ValidationPipe за пределами базы: whitelist срезает поля без декораторов и убивает mass-assignment, но лишь как один слой; вложенным DTO нужны @ValidateNested + @Type, иначе валидация молча no-op; для PATCH нужен PartialType, а не skipMissingProperties.

NEST Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

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 }`. Что происходит?

Вспомните перед уходом
  1. 01
    Объясни уязвимость mass-assignment в эндпоинте регистрации NestJS: как она возникает, что делает whitelist, почему whitelist в одиночку недостаточен и полный фикс.
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.