Bezpečnostní kontrolní seznam pro zpracování JSON
Shrnutí: `JSON.parse` sám je bezpečný; nebezpečí je v tom, co se stane po něm: hluboké slučování, přiřazení podle cesty, vkládání do HTML, neomezená hloubka a velikost.

Stručně: nejprve uzavřete dva vstupní body – každé hluboké sloučení a přiřazení podle cesty musí odmítnout __proto__, prototype a constructor a velikost těla požadavku i hloubka vnoření potřebují tvrdé limity platné ještě před parsováním. Sledovat pouze JSON.parse nechává nebezpečné dveře dokořán.
Útočník nemusí vymýšlet novou zranitelnost; stačí mu přidat do JSON jeden klíč.
1. Prototype pollution: nejčastější a nejvíce přehlížená
1.1 JSON.parse sám je bezpečný
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse vytváří __proto__ jako obyčejnou vlastní vlastnost a řetězec prototypů nepřepisuje. Takže tvrzení, že „JSON.parse způsobuje prototype pollution“, je nepřesné.
1.2 Nebezpečné je sloučení, které následuje
/**
* Nebezpečné hluboké sloučení: zapisuje klíče zdrojového objektu přímo do cílového objektu.
* @param target Cílový objekt
* @param source Zdrojový objekt (může pocházet z JSON uživatele)
* @returns Sloučený objekt
*/
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 — celý web je znečištěn
constructor.prototype je dalším vstupním bodem stejného druhu:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 Jak to opravit
/** Názvy klíčů, které musí být odmítnuty */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Bezpečné hluboké sloučení: odmítá klíče související s prototypem a zpracovává jen vlastní vlastnosti.
* @param target Cílový objekt
* @param source Zdrojový objekt
* @returns Nový sloučený objekt
*/
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;
}
// Použijte defineProperty, aby setter nemohl vyvolat mutaci prototypu
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
Důkladnější přístup je použít Object.create(null) jako kontejner (neexistuje řetězec prototypů ke znečištění), nebo prostě Map.
Při použití hotové knihovny ověřte, že tyto tři klíče už ošetřuje – mnoho starších verzí merge v lodash bylo hlášeno právě kvůli tomuto.
1.4 Přiřazení podle cesty je stejně nebezpečné
// Anti-vzor: cesta pochází ze vstupu uživatele
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
Když se v pathSegments objeví __proto__, je to ekvivalent znečištění výše. Implementace JSON Pointer / JSON Patch musí validovat každý segment tokenu.
2. Útoky vyčerpání zdrojů
| Útok | Taktika | Zmírnění |
|---|---|---|
| Hluboké vnoření | Sto tisíc úrovní [[[[...]]]] |
Omezit hloubku vnoření (obvykle 32–64) a proskenovat před parsováním |
| Příliš velká pole | Pole s desítkami milionů prvků | Omezit velikost těla požadavku a délku pole |
| Extrémně dlouhé řetězce | Jediný řetězec o stovkách MB | Omezit délku pole |
| Exploze duplicitních klíčů | Obrovské množství duplicitních klíčů | Omezit počet klíčů objektu |
Dvě nejúčinnější obrany na serveru jsou limity velikosti těla požadavku a limity hloubky, a obě musí účinkovat dřív, než dojde k jakémukoli parsování.
/**
* Levně zkontroluje hloubku vnoření před parsováním, aby se zabránilo přetečení zásobníku během parsování.
* @param text Text JSON
* @param maxDepth Maximální povolená hloubka
* @returns Zda je hloubka v povoleném rozsahu
*/
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. Injektáž při vkládání do HTML
Zapisovat JSON přímo do stránky je běžná praxe, ale přináší tři třídy problémů:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
</script> uvnitř řetězce uzavře blok skriptu předčasně a zbytek se parsuje jako HTML – klasické XSS.
Dva další znaky se chovají stejně: \u2028 (oddělovač řádku) a \u2029 (oddělovač odstavce) jsou v JSON legální, ale jejich vložení do zdrojového kódu JS vyvolá syntaktickou chybu.
Oprava:
/**
* Bezpečně serializuje JSON pro inline skript.
* @param value Datový objekt
* @returns Řetězec, který lze bezpečně umístit do značky skriptu
*/
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');
}
Robustnější přístup je vložit data do <script type="application/json"> (jehož obsah se nevykonává) a poté je načíst pomocí JSON.parse(document.getElementById('data').textContent).
4. JSON hijacking a JSONP
Rané prohlížeče umožňovaly cross-origin <script src="https://api.example.com/user"> přímo vykonat vrácený JavaScript
a JSON, jehož nejvyšší úrovní je pole, je zároveň platným výrazem JS, takže útočník mohl přečíst data přepsáním konstruktoru Array.
Moderní prohlížeče už mají ochrany, ale stále doporučujeme:
- Na nejvyšší úrovni API používat objekt, nikoli pole (
{"data": [...]}); - Posílat
X-Content-Type-Options: nosniffa striktně nastavitContent-Type: application/json; - Přestat používat JSONP; když je cross-origin přístup skutečně potřeba, použijte CORS.
5. Typová záměna při deserializaci do objektů
Jakmile je v Jacksonu zapnuté výchozí typování (enableDefaultTyping), útočník může určit pole @class a přimět server instancovat libovolnou třídu,
a v kombinaci s využitelným řetězcem gadgetů dosáhnout vzdáleného spuštění kódu. Zranitelnosti tohoto druhu se objevily mnohokrát (zasáhly Fastjson i Jackson).
Princip: cílový typ deserializace musí být staticky určen kódem serveru a nikdy ho nesmí určovat obsah payloadu.
6. Odmítnutí služby v regulárních výrazech a schématech
patternv JSON Schema imatch()v JSONPath přijímají regulární výrazy;- Vzory jako
(a+)+$na dlouhých vstupech backtracují exponenciálně; - Pokud regulární výraz pochází od uživatelů (vlastní validační pravidla), musíte omezit jeho délku, zakázat zpětné reference a vnořené kvantifikátory, nebo přejít k lineárnímu enginu, jako je RE2.
7. Logování a maskování
JSON logy jsou nejsnadnější cestou k úniku citlivých informací:
/** Názvy polí, která musí být v logech maskována (porovnání malými písmeny) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Rekurzivně maskuje citlivá pole v objektu pro výstup do logu.
* @param value Původní hodnota
* @returns Maskovaná kopie
*/
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;
}
Pozor, maskování musí proběhnout před zápisem logu, nikoli úklidem v logovací platformě později.
8. Kontrolní seznam před nasazením
- [ ] Všechny funkce hlubokého sloučení odmítají
__proto__/prototype/constructor; - [ ] Tokeny cest JSON Pointer / Patch jsou validovány proti allowlistu;
- [ ] Velikost těla požadavku a hloubka vnoření mají tvrdé limity a tyto limity účinkují před parsováním;
- [ ] JSON vkládaný do HTML escapuje
<,>,&,U+2028aU+2029; - [ ] API používá na nejvyšší úrovni objekt a odpovědi posílají
nosniff; - [ ] Cílové typy deserializace jsou staticky určeny v kódu;
- [ ] Regulární výrazy ovládané uživatelem mají limity složitosti;
- [ ] Pole v logech mají maskovací pravidla a tato pravidla jsou testována.
Časté dotazy
Jak zjistit, zda moje hluboké sloučení je zranitelné prototype pollution?
Předejte do slučovací funkce JSON.parse('{"__proto__": {"isAdmin": true}}') a poté zkontrolujte, zda se {}.isAdmin stalo true; zopakujte s payloadem constructor.prototype. U knihovny třetí strany ověřte, že odmítá __proto__, prototype a constructor – mnoho starších verzí merge v lodash bylo hlášeno právě kvůli tomuto. Přiřazení podle cesty potřebuje vlastní kontrolu, protože token __proto__ v cestě znečišťuje stejně.
Proč JSON.parse sám není zdrojem prototype pollution?
Vytváří __proto__ jako obyčejnou vlastní vlastnost a řetězec prototypů nikdy nepřepisuje, takže data.polluted i {}.polluted zůstávají undefined. Znečištění vzniká později: nebezpečné hluboké sloučení zapisuje klíče zdrojového objektu přímo do cíle a constructor.prototype je vstupní bod stejného druhu. Považovat JSON.parse za kořenovou příčinu znamená opravovat špatné místo.
Má se JSON vkládaný do HTML vždy escapovat?
Ano. </script> uvnitř řetězce uzavře blok skriptu předčasně a zbytek se parsuje jako HTML – klasické XSS; escapovat je třeba také <, >, &, U+2028 a U+2029. Bezpečnější možností je vložit data do <script type="application/json">, jehož obsah se nevykonává, a načíst je pomocí JSON.parse nad textContent.