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

Главное: сначала диагностируйте по симптому — парсинг, отрисовка или память — затем пройдите порядок исправлений: сократите объём данных, прекратите глубокое клонирование, перенесите парсинг в Worker с виртуальной прокруткой и организуйте потоковую обработку всего, что больше 50 МБ. Для недоверенного ввода ограничьте размер и глубину вложенности, прежде чем фильтровать __proto__, constructor и prototype.
«JSON медленный» в девяти случаях из десяти неточно; на самом деле медленными обычно оказываются отрисовка, глубокое клонирование или неверный порядок операций.
1. Почему это медленно
Парсинг большого JSON-файла медленный. Как его оптимизировать?
Сначала найдите узкое место, об оптимизации говорите потом. Этапы различаются на несколько порядков:
| Этап | Примерное время для 10 МБ данных | Почему это узкое место |
|---|---|---|
| Передача по сети | Зависит от пропускной способности | Несжатый JSON обычно сжимается примерно в 5 раз |
JSON.parse |
20–80 мс | Нативная реализация, уже быстрая |
| Рекурсивный обход / глубокое клонирование | 200–2000 мс | Обработка узлов на чистом JS один за другим |
| Отрисовка DOM (дерево) | Секунды, легко подвешивает | Количество узлов взрывается |
Поэтому порядок оптимизации должен быть таким:
- Сократите объём данных: настройте пагинацию в API, возвращайте только нужные поля и включите gzip/brotli;
- Избегайте глубокого клонирования: см. следующий раздел; это самая частая скрытая цена;
- Ленивая отрисовка: в дереве отрисовывайте только раскрытые уровни, а не создавайте десятки тысяч узлов DOM сразу;
- Потоковая обработка: не парсите всё сразу, когда данные превышают 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 занимает от сотен миллисекунд до секунд, а каждая последующая прокрутка вызывает перерасчёт макета.
Правильный подход:
- Виртуальная прокрутка: отрисовывайте только те несколько десятков строк, что в области просмотра, и переиспользуйте узлы DOM при прокрутке;
- Свёрнутость по умолчанию: раскрывайте только первые два уровня и отрисовывайте более глубокие по требованию;
- Ограничение масштаба отрисовки: выше 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 и подобные операции после парсинга.
Три защиты:
- Фильтруйте имена ключей: пропускайте
__proto__,constructorиprototypeпри слиянии; - Используйте
Object.create(null)как контейнер, чтобы не было цепочки прототипов для загрязнения; - Используйте
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?
Делайте это в таком порядке — низкая цена, высокая отдача:
- Ограничьте размер: сразу отклоняйте всё, что выше порога, не читая это в память;
- Ограничьте глубину: JSON, вложенный на сотни уровней, может переполнить стек рекурсивного парсера;
- Ограничьте количество ключей: предотвращает атаки на коллизии хешей и усиление памяти;
- Фильтруйте опасные ключи:
__proto__,constructor,prototype; - Валидируйте структуру: прогоните через Schema, чтобы подтвердить типы и обязательные поля;
- Не доверяйте
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?
Три:
- Чувствительная информация записывается на диск: телефоны, номера удостоверений, токены и пароли в открытом виде попадают в систему логирования, у которой обычно более слабые права и более долгое хранение;
- Инъекция в лог: значение JSON, содержащее переводы строк и поддельные префиксы лога, разбивает одну запись на несколько и загрязняет аудит;
- Взрыв размера: запись целого 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 уровнями |
| Логи заполняются | Логируется весь ответ | Обрезать + редактировать |