Чек-лист ризиків безпеки під час обробки JSON
Головне: Сам JSON.parse безпечний; небезпека в тому, що відбувається після нього: глибоке злиття, присвоєння за шляхом, вбудовування в HTML, необмежена глибина й розмір.

Коротко: спершу закрийте дві точки входу — кожне глибоке злиття й присвоєння за шляхом має відхиляти __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.