Контрольный список рисков безопасности при обработке 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 Как это исправить
/** Имена ключей, которые должны отвергаться */
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.