JSON Toolkit

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.

Středně pokročilý~12 min čteníPublikováno 2026-10-30Aktualizováno 2026-10-30
Titulní obrázek článku Bezpečnostní kontrolní seznam pro zpracování JSON

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: nosniff a striktně nastavit Content-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

  • pattern v JSON Schema i match() 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+2028 a U+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.