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 Як це виправити

/** Key names that must be rejected */
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;
    }

    // Use defineProperty so a setter cannot trigger prototype mutation
    Object.defineProperty(result, key, {
      value: sourceValue,
      enumerable: true,
      writable: true,
      configurable: true,
    });
  }

  return result;
}

Ґрунтовніший підхід — використовувати Object.create(null) як контейнер (там немає ланцюга прототипів, який можна забруднити) або просто Map. Використовуючи готову бібліотеку, підтвердьте, що вона вже обробляє ці три ключі — саме за це повідомляли про багато старіших версій merge у lodash.

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 — найлегший спосіб злити чутливу інформацію:

/** Field names that must be masked in logs (matched in lowercase) */
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 — саме за це повідомляли про багато старіших версій merge у lodash. Присвоєння за шляхом потребує власної перевірки, бо токен __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.