Почему опасно менять прототипы базовых типов в JS

Почему опасно писать прямо в прототипы базовых типов?

Теория: прототипы и цепочка

В JavaScript у объектов есть внутренняя ссылка на прототип (часто объясняется как “цепочка прототипов”), по которой движок ищет свойства и методы, если их нет в самом объекте. У массивов методы лежат в Array.prototype, у строк — в String.prototype, и почти все объекты в итоге наследуют от Object.prototype.

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

Схема поиска свойства:

Object instance
  |
  v
(собственные свойства)
  |
  v
SomeType.prototype
  |
  v
Object.prototype
  |
  v
null
Запись в SomeType.prototype меняет “общую базу методов” для всех экземпляров этого типа, включая уже созданные.

Почему это опасно

Глобальные побочные эффекты

Добавление, например, Array.prototype.myMethod влияет на все массивы в приложении и во всех подключённых зависимостях. Из-за этого ошибка может проявиться в другом месте: код меняется в одном файле, а ломается логика в библиотеке, которая никогда не ожидала появления нового свойства с таким именем.

Пример “глобальности”:

/ Изменение прототипа массива (опасная практика)
Array.prototype.sum = function () {
  return this.reduce((a, b) => a + b, 0);
};

console.log([1, 2, 3].sum()); // 6

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

Конфликты имён (с библиотеками и будущим стандартом)

Если добавляется метод с популярным именем, то существует риск, что:

  • библиотека уже добавляет метод с таким же именем, но с другой логикой;
  • другая часть проекта рассчитывает, что такого метода не существует;
  • среда выполнения позже добавит стандартный метод с таким именем, но с иным контрактом.

Пример конфликта имён (идея упрощена):

// Приложение добавляет метод с коротким именем
String.prototype.format = function () {
  return "локальная логика форматирования";
};

// Где-то в зависимости ожидается другое поведение format()
// или в будущем стандарт добавит одноимённый метод с другой семантикой
console.log("x".format());
Конфликт имён особенно опасен тем, что не всегда приводит к явной ошибке: код может “просто работать иначе”, незаметно и неправильно.

Неожиданное перечисление свойств

Некоторые конструкции перебирают свойства, включая унаследованные (например, for...in). Если в прототип добавлено перечислимое свойство, оно может внезапно появиться в переборе и сломать алгоритм, который ожидает только индексы массива или только реальные поля объекта.

Пример ошибки при перечислении:

// Добавление перечислимого свойства в прототип (опасно)
Array.prototype.extra = 123;

const arr = [10, 20];

for (const k in arr) {
  // Ожидаются "0" и "1", но также может появиться "extra"
  console.log(k);
}

Даже если метод добавляется через Object.defineProperty(..., { enumerable: false }), остаются другие риски: глобальное изменение поведения, конфликты имён, влияние на проверки наличия свойства и на совместимость.

Риски безопасности: prototype pollution

Prototype pollution — ситуация, когда из-за обработки непроверенных данных в прототипы (часто в Object.prototype) попадают новые свойства. После этого эти свойства становятся “видимыми” для многих объектов и способны повлиять на проверки, условия и настройки по всему приложению.

Типичный источник проблемы — “слияние” объектов, пришедших извне (например, из JSON), без фильтрации опасных ключей.

Пример, демонстрирующий идею (упрощённо):

function merge(target, source) {
  for (const key in source) {
    target[key] = source[key];
  }
  return target;
}

// Непроверенный ввод
const payload = JSON.parse('{"__proto__":{"isAdmin":true}}');

const user = {};
merge(user, payload);

// Опасный эффект: свойство может начать "как бы существовать" у многих объектов
const obj = {};
console.log(obj.isAdmin); // потенциально true в уязвимых сценариях
Даже “маленькое” изменение прототипов может стать причиной обхода проверок доступа, если где-то в коде используется логика наподобие if (obj.isAdmin) ... без строгой валидации источника этого значения.

Как правильно решать задачу

Надёжнее не менять встроенные прототипы, а выносить функциональность в обычные функции, модули или классы-обёртки. Такой код проще тестировать, проще читать и невозможно “случайно” навязать всем объектам в программе.

Утилитные функции

Пример замены Array.prototype.unique на обычную функцию:

function unique(arr) {
  return Array.from(new Set(arr));
}

console.log(unique([1, 1, 2])); // [1, 2]

Плюсы: отсутствуют глобальные побочные эффекты, проще типизировать, проще переиспользовать, легче сопровождать.

Безопасные структуры вместо “словаря на Object”

Если необходима структура “ключ → значение”, часто безопаснее Map, потому что он не использует Object.prototype как часть механики доступа к ключам.

const m = new Map();
m.set("__proto__", "ok");
console.log(m.get("__proto__")); // "ok"

Полифилы только по строгой необходимости

Единственный относительно оправданный случай изменения прототипа — полифил стандартного метода для старого окружения, когда:

  • метод отсутствует;
  • добавление точно повторяет контракт стандарта;
  • свойство делается неперечислимым;
  • решение документировано и контролируется (обычно в одном месте проекта).

Пример “аккуратного” добавления (упрощённая форма):

if (!Array.prototype.myPolyfilledMethod) {
  Object.defineProperty(Array.prototype, "myPolyfilledMethod", {
    value: function () {
      return "реализация";
    },
    enumerable: false,
    writable: true,
    configurable: true
  });
}
Полифил, который ведёт себя “почти как стандарт”, часто хуже отсутствия метода: создаётся ложное ощущение совместимости.

Защита при слиянии объектов

При обработке внешнего ввода следует исключать опасные ключи (__proto__, constructor, prototype) и по возможности создавать объекты без прототипа для роли “чистого словаря”.

Пример:

function safeAssign(target, source) {
  for (const key of Object.keys(source)) {
    if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
    target[key] = source[key];
  }
  return target;
}

const dict = Object.create(null);
safeAssign(dict, JSON.parse('{"a":1,"__proto__":{"polluted":true}}'));

console.log(dict.a); // 1

Таблица рисков и замен

РискКак проявляетсяБезопасная альтернатива
Глобальные изменения поведения“Ломается” код в другом модуле или зависимостиУтилитные функции, отдельные модули
Конфликты имёнОдинаковое имя метода, разный контрактЯвный импорт функции, неймспейсы
Неожиданное перечислениеЛишние ключи в for...in, некорректные проверкиObject.keys(), строгая проверка собственных свойств
Prototype pollutionВнешний ввод влияет на прототипы и проверкиФильтрация ключей, Object.create(null), Map

В итоге: опасность записи прямо в прототипы базовых типов (например, Object.prototype, Array.prototype, String.prototype) заключается в том, что меняется поведение сразу у всех значений данного типа во всей программе, включая сторонние библиотеки и будущие обновления среды. Это приводит к труднообнаружимым ошибкам, конфликтам имён, проблемам совместимости и иногда к уязвимостям, связанным с загрязнением прототипов (prototype pollution).