JSON Toolkit

Контрольный список рисков безопасности при обработке JSON

Главное: JSON.parse сам по себе безопасен; опасность в том, что происходит после него: глубокое слияние, присваивание по пути, встраивание в HTML, неограниченные глубина и размер.

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

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

Атакующим не нужно изобретать новую уязвимость; они просто добавляют ещё один ключ в JSON.

1. Загрязнение прототипа: самое частое и самое упускаемое

1.1 JSON.parse сам по себе безопасен

const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted);        // undefined
console.log({}.polluted);          // undefined

JSON.parse создаёт __proto__ как обычное собственное свойство и не переписывает цепочку прототипов. Поэтому утверждение, что «JSON.parse вызывает загрязнение прототипа», неточно.

1.2 Опасность в слиянии, которое следует

/**
 * Unsafe deep merge: writes the source object's keys straight into the target object.
 * @param target Target object
 * @param source Source object (may come from user JSON)
 * @returns Merged object
 */
function unsafeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = target[key] ?? {};
      unsafeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin);    // true — весь сайт загрязнён

constructor.prototype — другая входная точка того же рода:

{ "constructor": { "prototype": { "isAdmin": true } } }

1.3 Как это исправить

/** Имена ключей, которые должны отвергаться */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

/**
 * Safe deep merge: rejects prototype-related keys and handles own properties only.
 * @param target Target object
 * @param source Source object
 * @returns New merged object
 */
function safeMerge(target, source) {
  const result = { ...target };

  for (const key of Object.keys(source)) {
    if (FORBIDDEN_KEYS.has(key)) {
      continue;
    }
    const sourceValue = source[key];
    const isPlainObject =
      typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);

    if (isPlainObject) {
      result[key] = safeMerge(target[key] ?? {}, sourceValue);
      continue;
    }

    // Используйте defineProperty, чтобы сеттер не мог вызвать мутацию прототипа
    Object.defineProperty(result, key, {
      value: sourceValue,
      enumerable: true,
      writable: true,
      configurable: true,
    });
  }

  return result;
}

Более основательный подход — использовать Object.create(null) как контейнер (нет цепочки прототипов для загрязнения) или просто Map. При использовании готовой библиотеки подтвердите, что она уже обрабатывает эти три ключа — многие старые версии lodash merge были отмечены именно за это.

1.4 Присваивание по пути столь же опасно

// Антипаттерн: путь приходит из пользовательского ввода
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

Когда __proto__ появляется в pathSegments, это эквивалентно загрязнению выше. Реализации JSON Pointer / JSON Patch должны валидировать каждый сегмент токена.

2. Атаки на исчерпание ресурсов

Атака Тактика Смягчение
Глубокая вложенность Сто тысяч уровней [[[[...]]]] Ограничить глубину вложенности (обычно 32–64) и предварительно сканировать перед парсингом
Огромные массивы Массив с десятками миллионов элементов Ограничить размер тела запроса и длину массива
Чрезвычайно длинные строки Одна строка в сотни МБ Ограничить длину поля
Взрыв дублирующихся ключей Огромное число дублирующихся ключей Ограничить число ключей объекта

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

/**
 * Cheaply checks nesting depth before parsing to prevent stack overflow during parsing.
 * @param text JSON text
 * @param maxDepth Maximum allowed depth
 * @returns Whether the depth is within the allowed range
 */
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
  let currentDepth = 0;
  let isInsideString = false;
  let isEscaped = false;

  for (const character of text) {
    if (isInsideString) {
      if (isEscaped) {
        isEscaped = false;
      } else if (character === '\\') {
        isEscaped = true;
      } else if (character === '"') {
        isInsideString = false;
      }
      continue;
    }

    if (character === '"') {
      isInsideString = true;
    } else if (character === '{' || character === '[') {
      currentDepth += 1;
      if (currentDepth > maxDepth) {
        return false;
      }
    } else if (character === '}' || character === ']') {
      currentDepth -= 1;
    }
  }

  return true;
}

3. Инъекция при встраивании в HTML

Прямая запись JSON в страницу — распространённая практика, но она вносит три класса проблем:

<script>
  window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>

</script> внутри строки преждевременно закрывает блок скрипта, и остальное разбирается как HTML — классический XSS.

Ещё два символа ведут себя так же: \u2028 (разделитель строк) и \u2029 (разделитель абзацев) легальны в JSON, но их встраивание в исходник JS вызывает синтаксическую ошибку.

Исправление:

/**
 * Serializes JSON safely for an inline script.
 * @param value Data object
 * @returns String that can be safely placed inside a script tag
 */
function serializeForInlineScript(value: unknown): string {
  return JSON.stringify(value)
    .replace(/</g, '\\u003c')
    .replace(/>/g, '\\u003e')
    .replace(/&/g, '\\u0026')
    .replace(/\u2028/g, '\\u2028')
    .replace(/\u2029/g, '\\u2029');
}

Более надёжный подход — поместить данные в <script type="application/json"> (чьё содержимое не исполняется), затем читать их через JSON.parse(document.getElementById('data').textContent).

4. Перехват JSON и JSONP

Ранние браузеры позволяли кросс-доменному <script src="https://api.example.com/user"> исполнять возвращённый JavaScript напрямую, и JSON, верхний уровень которого является массивом, случайно оказывается валидным JS-выражением, поэтому атакующий мог читать данные, переопределив конструктор Array.

Современные браузеры уже имеют защиты, но мы всё же рекомендуем:

  • Используйте объект, а не массив на верхнем уровне API ({"data": [...]});
  • Отправляйте X-Content-Type-Options: nosniff и строго устанавливайте Content-Type: application/json;
  • Прекратите использовать JSONP; используйте CORS, когда кросс-доменный доступ действительно нужен.

5. Путаница типов при десериализации в объекты

Когда в Jackson включена типизация по умолчанию (enableDefaultTyping), атакующий может указать поле @class, чтобы заставить сервер инстанцировать произвольный класс, и совместить это с эксплуатируемой цепочкой гаджетов для удалённого выполнения кода. Уязвимости такого рода появлялись много раз (пострадали и Fastjson, и Jackson).

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

6. Отказ в обслуживании в регулярных выражениях и схемах

  • pattern в JSON Schema и match() в JSONPath оба принимают регулярные выражения;
  • Шаблоны вроде (a+)+$ бэктрекают экспоненциально на длинном вводе;
  • Если регулярное выражение приходит от пользователей (пользовательские правила валидации), вы должны ограничить его длину, запретить обратные ссылки и вложенные квантификаторы или перейти на линейный движок вроде RE2.

7. Логирование и маскирование

Логи JSON — самый простой способ утечки чувствительной информации:

/** Имена полей, которые должны маскироваться в логах (сопоставление в нижнем регистре) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);

/**
 * Recursively masks sensitive fields in an object for log output.
 * @param value Original value
 * @returns Masked copy
 */
function maskSensitive(value: unknown): unknown {
  if (Array.isArray(value)) {
    return value.map((item) => maskSensitive(item));
  }
  if (typeof value === 'object' && value !== null) {
    return Object.fromEntries(
      Object.entries(value).map(([entryKey, entryValue]) => {
        if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
          return [entryKey, '***'];
        }
        return [entryKey, maskSensitive(entryValue)];
      }),
    );
  }
  return value;
}

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

8. Контрольный список перед запуском

  • [ ] Все функции глубокого слияния отвергают __proto__ / prototype / constructor;
  • [ ] Токены пути JSON Pointer / Patch валидируются по белому списку;
  • [ ] Размер тела запроса и глубина вложенности имеют жёсткие лимиты, и эти лимиты применяются до парсинга;
  • [ ] JSON, встроенный в HTML, экранирует <, >, &, U+2028 и U+2029;
  • [ ] API используют объект на верхнем уровне, а ответы отправляют nosniff;
  • [ ] Целевые типы десериализации статически заданы в коде;
  • [ ] Пользовательские регулярные выражения имеют ограничения сложности;
  • [ ] Поля логов имеют правила маскирования, и эти правила тестируются.

Вопросы и ответы

Как проверить, уязвимо ли моё глубокое слияние к загрязнению прототипа?

Прогоните JSON.parse('{"__proto__": {"isAdmin": true}}') через функцию слияния, затем проверьте, стало ли {}.isAdmin равно true; повторите с полезной нагрузкой constructor.prototype. С сторонней библиотекой подтвердите, что она отвергает __proto__, prototype и constructor — многие старые версии lodash merge были отмечены именно за это. Присваивание по пути нуждается в собственной проверке, потому что токен __proto__ в пути загрязняет тем же образом.

Почему сам JSON.parse не является источником загрязнения прототипа?

Он создаёт __proto__ как обычное собственное свойство и никогда не переписывает цепочку прототипов, поэтому и data.polluted, и {}.polluted остаются undefined. Загрязнение происходит потом: небезопасное глубокое слияние записывает ключи исходного объекта прямо в цель, а constructor.prototype — входная точка того же рода. Трактовка JSON.parse как корневой причины означает исправление не того места.

Стоит ли всегда экранировать JSON, встроенный в HTML?

Да. </script> внутри строки преждевременно закрывает блок скрипта, и остальное разбирается как HTML — классический XSS; <, >, &, U+2028 и U+2029 тоже нуждаются в экранировании. Более безопасный вариант — поместить данные в <script type="application/json">, чьё содержимое не исполняется, и читать их через JSON.parse от textContent.