Предотвращение prototype pollution

Prototype pollution — это тип уязвимости, при котором злоумышленник влияет на прототипы встроенных объектов JavaScript через специально сформированные входные данные. В результате изменяются свойства, доступные всем объектам, что приводит к непредсказуемому поведению приложения, обходу проверок и потенциальному выполнению нежелательной логики.

В экосистеме Node.js эта проблема часто возникает при работе с входящими JSON-объектами, особенно когда они автоматически преобразуются в экземпляры классов без строгого контроля полей. Библиотека class-validator используется для декларативной валидации DTO (Data Transfer Objects), но сама по себе не гарантирует защиту от prototype pollution без корректной настройки окружения и дополнительных механизмов фильтрации.


Источники возникновения уязвимости при работе с объектами

JavaScript позволяет динамически добавлять и изменять свойства объектов. Особую опасность представляют ключи:

  • __proto__
  • constructor
  • prototype

При некорректной обработке входных данных эти ключи могут быть интерпретированы как обычные свойства объекта, что приводит к изменению цепочки прототипов.

Пример опасной структуры входных данных:

{
  "__proto__": {
    "isAdmin": true
  }
}

Если такой объект будет без фильтрации объединён с другими объектами или пройдет через небезопасный парсинг, свойство isAdmin может появиться у всех объектов приложения.


Роль class-validator и смежных инструментов

class-validator работает поверх экземпляров классов и проверяет значения свойств, используя декораторы:

  • @IsString()
  • @IsNumber()
  • @IsOptional()
  • @ValidateNested()

Однако ключевой момент заключается в том, что библиотека не выполняет автоматическую фильтрацию входных ключей. Она предполагает, что объект уже безопасно сформирован до валидации.

Часто class-validator используется вместе с class-transformer, который преобразует plain-object в класс:

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

const dto = plainToInstance(UserDto, request.body);
await validate(dto);

Без дополнительной защиты plainToInstance может пропустить опасные ключи, если не настроена строгая политика трансформации.


Механизм возникновения pollution через трансформацию

При преобразовании объекта происходит присваивание свойств:

class UserDto {
  name: string;
}

Вход:

{
  "name": "Alice",
  "__proto__": {
    "polluted": true
  }
}

Если не включены ограничения, __proto__ может быть обработан как обычное поле, особенно при небезопасных merge-операциях или ручном копировании через spread:

const unsafe = { ...request.body };

Этот код не блокирует специальные ключи.


Стратегия защиты через строгую валидацию полей

Основной принцип защиты: разрешать только явно описанные поля DTO и отклонять всё остальное.

В экосистеме NestJS это достигается через ValidationPipe, но аналогичные подходы применимы и без него.

Белый список свойств

При включении whitelist лишние поля удаляются:

{
  whitelist: true
}

Поведение:

  • разрешённые свойства остаются
  • неизвестные удаляются

Это снижает риск попадания __proto__ в дальнейшую обработку.


Запрет неизвестных полей

Более строгий режим:

{
  whitelist: true,
  forbidNonWhitelisted: true
}

При этом любые дополнительные поля вызывают ошибку валидации, а не просто игнорируются.

Это критично для защиты от prototype pollution, поскольку атака часто опирается на незаметное добавление служебных ключей.


Защита на уровне трансформации class-transformer

При использовании class-transformer важно ограничивать преобразование:

plainToInstance(UserDto, payload, {
  exposeDefaultValues: false,
  enableImplicitConversion: false
});

Дополнительно применяются:

  • excludeExtraneousValues (при использовании @Exclude, @Expose)
  • явное перечисление полей через @Expose

Пример безопасного DTO:

import { Expose } from 'class-transformer';
import { IsString } from 'class-validator';

export class UserDto {
  @Expose()
  @IsString()
  name: string;
}

Только помеченные поля попадают в объект.


Фильтрация опасных ключей вручную

В случаях, когда входные данные поступают из внешних источников без промежуточного слоя валидации, применяется ручная очистка:

const BLOCKED_KEYS = ['__proto__', 'constructor', 'prototype'];

function sanitize(input: Record<string, any>) {
  for (const key of Object.keys(input)) {
    if (BLOCKED_KEYS.includes(key)) {
      delete input[key];
    }
  }
  return input;
}

Более строгий вариант — рекурсивная очистка вложенных объектов.


Риски при использовании spread и merge операций

Частая причина prototype pollution — небезопасное объединение объектов:

const result = {
  ...defaultConfig,
  ...userInput
};

Если userInput содержит специальные ключи, они могут попасть в итоговый объект.

Особенно опасны:

  • глубокие merge-функции (lodash.merge старых версий)
  • рекурсивные копирования без фильтрации
  • десериализация JSON с последующим assign

Ограничение глубины и структуры входных данных

Prototype pollution часто использует вложенные структуры:

{
  "a": {
    "b": {
      "__proto__": { "x": 1 }
    }
  }
}

Поэтому защита должна включать:

  • ограничение глубины DTO
  • строгую схему вложенных объектов через @ValidateNested
  • явное описание типов вложений

Пример:

import { Type } from 'class-transformer';
import { ValidateNested } from 'class-validator';

class ProfileDto {
  @IsString()
  bio: string;
}

class UserDto {
  @ValidateNested()
  @Type(() => ProfileDto)
  profile: ProfileDto;
}

Поведение class-validator при нестандартных ключах

class-validator:

  • не валидирует поля, которых нет в классе
  • не предотвращает их появление
  • игнорирует лишние свойства при проверке

Это означает, что ответственность за фильтрацию лежит на этапе трансформации и конфигурации pipeline.


Усиление защиты через архитектурные ограничения

На уровне архитектуры применяются следующие принципы:

Явные DTO границы

Каждый входящий объект должен иметь строго определённую структуру без динамических ключей.

Отказ от динамического присваивания

Избегается использование:

  • Object.assign с пользовательскими данными
  • глубоких merge без схемы
  • прямого копирования req.body

Централизованная валидация

Все входные данные проходят через единый слой обработки, где:

  • применяется whitelist
  • запрещены неизвестные поля
  • выполняется трансформация в классы

Примеры безопасной конфигурации пайплайна

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true
  })
);

Эта конфигурация обеспечивает:

  • удаление лишних полей
  • ошибку при наличии подозрительных ключей
  • автоматическое преобразование в DTO-классы

Связь prototype pollution с реальными уязвимостями

Prototype pollution редко остаётся изолированной проблемой. Она становится базой для:

  • обхода авторизации через подмену флагов доступа
  • изменения логики проверки ролей
  • модификации конфигурационных объектов
  • инъекции в цепочки бизнес-логики

В связке с динамическим JavaScript это приводит к трудно обнаружимым дефектам, поскольку изменения происходят на уровне прототипа, а не конкретного объекта.


Практическая модель безопасной обработки входных данных

Безопасная цепочка обработки включает:

  1. Приём сырого JSON
  2. Удаление потенциально опасных ключей
  3. Преобразование в DTO
  4. Валидация через class-validator
  5. Использование только валидированных данных в бизнес-логике

Ключевой принцип — ни один пользовательский объект не должен попадать в логику без прохождения всех этапов фильтрации.


Ограничения библиотеки и ответственность разработчика

class-validator решает задачу проверки значений, но не занимается безопасностью структуры объекта на уровне прототипов. Защита от prototype pollution требует комбинирования нескольких слоёв:

  • трансформация входных данных
  • строгая схема DTO
  • фильтрация неизвестных полей
  • запрет опасных ключей
  • контроль merge-операций

Только совокупность этих механизмов формирует устойчивую защиту от изменения прототипов через пользовательский ввод.