JSON Toolkit

Вопросы о производительности и безопасности: медленно, подвисает и недоверенные данные

Главное: Почему JSON-файл в десятки мегабайт не открывается, насколько дорого глубокое клонирование, как предотвратить загрязнение __proto__ и откуда проникает XSS — десять выводов первой помощи, упорядоченных по симптому.

Средний~13 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка вопросов о производительности и безопасности

Главное: сначала диагностируйте по симптому — парсинг, отрисовка или память — затем пройдите порядок исправлений: сократите объём данных, прекратите глубокое клонирование, перенесите парсинг в Worker с виртуальной прокруткой и организуйте потоковую обработку всего, что больше 50 МБ. Для недоверенного ввода ограничьте размер и глубину вложенности, прежде чем фильтровать __proto__, constructor и prototype.

«JSON медленный» в девяти случаях из десяти неточно; на самом деле медленными обычно оказываются отрисовка, глубокое клонирование или неверный порядок операций.

1. Почему это медленно

Парсинг большого JSON-файла медленный. Как его оптимизировать?

Сначала найдите узкое место, об оптимизации говорите потом. Этапы различаются на несколько порядков:

Этап Примерное время для 10 МБ данных Почему это узкое место
Передача по сети Зависит от пропускной способности Несжатый JSON обычно сжимается примерно в 5 раз
JSON.parse 20–80 мс Нативная реализация, уже быстрая
Рекурсивный обход / глубокое клонирование 200–2000 мс Обработка узлов на чистом JS один за другим
Отрисовка DOM (дерево) Секунды, легко подвешивает Количество узлов взрывается

Поэтому порядок оптимизации должен быть таким:

  1. Сократите объём данных: настройте пагинацию в API, возвращайте только нужные поля и включите gzip/brotli;
  2. Избегайте глубокого клонирования: см. следующий раздел; это самая частая скрытая цена;
  3. Ленивая отрисовка: в дереве отрисовывайте только раскрытые уровни, а не создавайте десятки тысяч узлов DOM сразу;
  4. Потоковая обработка: не парсите всё сразу, когда данные превышают 10 МБ.

Если узкое место действительно в JSON.parse, значит данных уже десятки мегабайт или больше, и правильный шаг — потоковый парсер:

import { parser } from 'stream-json';
import { streamArray } from 'stream-json/streamers/StreamArray';

// Обрабатывайте элементы массива по одному; потребление памяти не зависит от общего размера
const pipeline = fs.createReadStream('big.json')
  .pipe(parser())
  .pipe(streamArray());

pipeline.on('data', ({ value }) => {
  processItem(value);
});

JSON в десятки МБ роняет браузер сразу при открытии. Что теперь?

Сначала выясните, «падает» он или «зависает». Эти два случая требуют совершенно разного подхода:

  • Зависание: вкладка показывает «Страница не отвечает» и может восстановиться через десятки секунд. Причина — длительные задачи, насыщающие главный поток;
  • Падение: вкладка превращается в «Aw, Snap», обычно из-за исчерпания памяти (OOM).

В любом случае не обрабатывайте это в главном потоке. Перенесите парсинг в Web Worker:

// worker.js
self.onmessage = (event) => {
  const parsed = JSON.parse(event.data);
  self.postMessage(parsed);
};

Worker решает проблему «интерфейс не подвисает», но не проблему потолка памяти. Вкладка браузера обычно имеет 1–4 ГБ; JSON в 200 МБ, разобранный в объекты, может занимать 1 ГБ и больше (каждая пара ключ-значение несёт накладные расходы объекта), и вместе с отрисовкой легко упирается в лимит.

Настоящее решение — никогда не загружать всё целиком:

Размер данных Подход
< 5 МБ Парсить напрямую через JSON.parse, следить за отрисовкой
5–50 МБ Парсинг в Worker + виртуальная прокрутка
> 50 МБ Потоковый парсинг, оставлять только нужные поля
> 500 МБ Сменить формат (Parquet / SQLite / шардированный JSON Lines)

Является ли JSON.parse(JSON.stringify(obj)) глубоким клонированием?

Это глубокое клонирование, но оно «молча меняет данные» в нескольких местах, поэтому не подходит как универсальный инструмент клонирования.

const source = {
  date: new Date(),
  fn: () => {},
  undef: undefined,
  nan: NaN,
  map: new Map(),
  nested: { a: 1 },
};
JSON.parse(JSON.stringify(source));
// {
//   date: "2026-10-30T03:00:00.000Z",  // Date становится строкой
//   nan: null,                          // NaN становится null
//   nested: { a: 1 }                    // корректен только этот уровень
// }
// fn и undef исчезают, map становится {}

Перечень проблем:

Тип Результат
Date Становится ISO-строкой
NaN / Infinity Становится null
undefined / функции / Symbol Исчезает внутри объектов, становится null внутри массивов
Map / Set Становится {}
BigInt Выбрасывает TypeError
Циклические ссылки Выбрасывает TypeError

В современных браузерах есть structuredClone, который поддерживает Date, Map, Set и циклические ссылки, но не функции:

const copy = structuredClone(source);

Если это всего лишь поверхностное слияние конфигурации, самый дешёвый вариант — оператор расширения: { ...config, timeout: 3000 }.

Почему раскрытие дерева так сильно подвисает?

Потому что каждый узел — это элемент DOM, и когда количество узлов достигает десятков тысяч, стоимость раскладки в браузере взрывается.

JSON в 10 МБ может содержать 50–100 тысяч листовых узлов. Даже если отрисовывать только видимую область, однократное построение DOM занимает от сотен миллисекунд до секунд, а каждая последующая прокрутка вызывает перерасчёт макета.

Правильный подход:

  1. Виртуальная прокрутка: отрисовывайте только те несколько десятков строк, что в области просмотра, и переиспользуйте узлы DOM при прокрутке;
  2. Свёрнутость по умолчанию: раскрывайте только первые два уровня и отрисовывайте более глубокие по требованию;
  3. Ограничение масштаба отрисовки: выше 10 тысяч узлов показывайте «Данные слишком велики, используйте поиск для навигации» вместо принудительной отрисовки.

Именно поэтому редактор на этом сайте корректно деградирует на больших файлах — не потому, что технически не может их отрисовать, а потому, что работать с ними было бы невозможно.

2. Почему атакуют

Как работает инъекция __proto__?

Загрязнение прототипа. Атакующий конструирует JSON с ключом __proto__; если ваш код сливает его в объект без фильтрации, он перезаписывает Object.prototype и влияет на каждый объект в среде выполнения.

const payload = JSON.parse('{"__proto__": {"isAdmin": true}}');

// Опасный код: рекурсивное слияние без проверки имён ключей
function merge(target, source) {
  for (const key in source) {
    if (typeof source[key] === 'object') {
      target[key] = target[key] || {};
      merge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

merge({}, payload);
({}).isAdmin;  // true — каждый объект загрязнён

Ключевой факт: сам JSON.parse не загрязняет прототип; {"__proto__": ...} разбирается в обычное собственное свойство. Опасны слияние, присваивание, Object.assign, lodash.merge и подобные операции после парсинга.

Три защиты:

  1. Фильтруйте имена ключей: пропускайте __proto__, constructor и prototype при слиянии;
  2. Используйте Object.create(null) как контейнер, чтобы не было цепочки прототипов для загрязнения;
  3. Используйте Map для хранения недоверенных пар ключ-значение.
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }
    const value = source[key];
    if (value && typeof value === 'object' && !Array.isArray(value)) {
      target[key] = safeMerge(target[key] ?? {}, value);
    } else {
      target[key] = value;
    }
  }
  return target;
}

Также учтите, что for...in обходит наследуемые свойства; Object.keys() безопаснее.

На что обращать внимание при парсинге недоверенного JSON?

Делайте это в таком порядке — низкая цена, высокая отдача:

  1. Ограничьте размер: сразу отклоняйте всё, что выше порога, не читая это в память;
  2. Ограничьте глубину: JSON, вложенный на сотни уровней, может переполнить стек рекурсивного парсера;
  3. Ограничьте количество ключей: предотвращает атаки на коллизии хешей и усиление памяти;
  4. Фильтруйте опасные ключи: __proto__, constructor, prototype;
  5. Валидируйте структуру: прогоните через Schema, чтобы подтвердить типы и обязательные поля;
  6. Не доверяйте Content-Type: заявленный тип и фактическое содержимое могут расходиться.

Проверка глубины дешёвая; можно использовать грубый фильтр по регулярному выражению перед парсингом или обходить после парсинга:

function measureDepth(value, depth = 0) {
  if (value === null || typeof value !== 'object') {
    return depth;
  }
  let maxDepth = depth;
  for (const key of Object.keys(value)) {
    maxDepth = Math.max(maxDepth, measureDepth(value[key], depth + 1));
  }
  return maxDepth;
}

Но этот код сам рекурсивный и всё ещё может переполнить стек на злонамеренно глубоких данных. Безопасный подход — применять ограничение во время парсинга, используя парсер, поддерживающий опцию глубины (некоторые библиотеки предоставляют параметр maxDepth).

Как через JSON происходит XSS?

Сам JSON — это не HTML и не исполняется. XSS происходит в точке, где «значение JSON конкатенируется в HTML».

Три распространённых пути:

Путь Пример Защита
Конкатенация в серверном шаблоне res.send('<div>' + data.name + '</div>') HTML-экранирование при выводе
Фронтенд innerHTML el.innerHTML = data.html Использовать textContent или санитайзер
Встраивание во встроенный скрипт <script>var d = ${JSON.stringify(data)}</script> Экранировать <, >, &

Третий путь самый тонкий. JSON.stringify не экранирует <, поэтому данные, содержащие </script><script>alert(1)</script>, могут преждевременно закрыть тег скрипта:

const data = { name: '</script><script>alert(1)</script>' };
// Вывод: <script>var d = {"name":"</script><script>alert(1)</script>"}</script>

Исправление — превратить опасные символы в Unicode-escape-последовательности:

function safeJsonForHtml(data) {
  return JSON.stringify(data)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

Стоит ли ограничивать глубину вложенности?

Да. Это одна из защит с лучшим соотношением цены и выгоды.

Последствия отсутствия ограничения: рекурсивные функции переполняют стек, JSON.parse падает в некоторых реализациях при слишком большой глубине, а сериализация переполняет стек тем же образом. Достаточно 1 КБ [[[[...]]]], чтобы уронить незащищённый парсер.

Разумный порог зависит от бизнеса:

Тип данных Рекомендуемый лимит
Обычные бизнес-объекты 10–20 уровней
Древовидные структуры (оргструктуры, каталоги) 50–100 уровней
Общий шлюз API 20 уровней

Что такое перехват JSON (JSON hijacking)?

Историческая уязвимость: ранние браузеры позволявали кросс-доменному <script src> загружать и исполнять JSON, а атакующие крали данные, переопределяя конструктор Array или Object.prototype.__defineSetter__.

Современные защиты довольно полны:

  • Браузеры больше не позволяют перехватывать литералы массивов через __defineSetter__;
  • Корректный Content-Type: application/json предотвращает исполнение скрипта;
  • X-Content-Type-Options: nosniff предотвращает MIME-sniffing.

Старые системы всё ещё несут остаточный риск. Практический совет: используйте объект на верхнем уровне, а не голый массив. Обёртка вида {"data": [...]} не даёт ответу быть валидным JavaScript-выражением ({ трактуется как блок кода), блокируя перехват на уровне синтаксиса.

Какие риски у логирования JSON?

Три:

  1. Чувствительная информация записывается на диск: телефоны, номера удостоверений, токены и пароли в открытом виде попадают в систему логирования, у которой обычно более слабые права и более долгое хранение;
  2. Инъекция в лог: значение JSON, содержащее переводы строк и поддельные префиксы лога, разбивает одну запись на несколько и загрязняет аудит;
  3. Взрыв размера: запись целого 10-мегабайтного ответа в лог быстро заполняет систему логирования.

Подход:

// Редактировать и обрезать перед логированием
function safeLog(data) {
  const cloned = structuredClone(data);
  for (const key of ['password', 'token', 'idCard', 'phone']) {
    if (key in cloned) cloned[key] = '***';
  }
  const text = JSON.stringify(cloned);
  return text.length > 2000 ? text.slice(0, 2000) + '...(truncated)' : text;
}

Структурированные системы логирования (например, pino) сами экранируют переводы строк, но редактирование и обрезка всё ещё на вас.

3. Быстрый справочник

Симптом Проверить сначала Исправление
Медленный парсинг Объём данных, участвует ли глубокое клонирование Разбиение/потоковая обработка, убрать JSON.parse(JSON.stringify())
Страница зависает Длительные задачи в главном потоке, число отрисованных узлов Worker + виртуальная прокрутка
Вкладка падает Использование памяти Не загружать всё; перейти на потоковую обработку
Глубокое клонирование теряет данные Date, NaN, Map Использовать structuredClone
У объектов внезапно появляются лишние свойства Загрязнение прототипа Фильтровать __proto__, использовать Map
Всплывает скрипт Строки конкатенируются в HTML Экранировать <, использовать textContent
Переполнение стека Глубина вложенности Ограничить 10–20 уровнями
Логи заполняются Логируется весь ответ Обрезать + редактировать