Bezpečnost JSON: limity a prototype pollution

JSON je datový formát, ale jeho načtení neověřuje oprávnění ani bezpečnost dalšího použití. Rizika vznikají při vyhodnocování, slučování objektů a práci s příliš velkým vstupem.

Autor: Aktualizováno

Parsuj a validuj

Použij JSON.parse nebo parser svého jazyka, nikdy eval. Pak ověř typy, povinné položky a limity schématem. Schéma není autorizace: uživatel nesmí sám rozhodovat, zda má roli admin nebo kterou cizí objednávku smí změnit.

Parsuj a validuj
const data = JSON.parse(text);
if (data === null || Array.isArray(data) || typeof data !== "object") {
  throw new Error("Očekávám objekt");
}
const user = { name: data.name }; // vybírej schválené položky

Klíč __proto__ a slučování

JSON.parse vytvoří z __proto__ obyčejnou vlastní položku. Riziko může přijít později: Object.assign do běžného objektu používá zápis přes setter, který pro tento klíč mění prototyp cíle. Nedělej neomezené hluboké slučování nedůvěryhodných objektů. Vybírej povolená pole a aktualizuj knihovny.

Klíč __proto__ a slučování
const input = JSON.parse('{"__proto__":{"admin":true}}');
console.log(Object.hasOwn(input, "__proto__")); // true
const target = Object.assign({}, input);
console.log(target.admin); // true, zděděné z prototypu cíle
console.log({}.admin); // undefined: tato ukázka nemění Object.prototype

Prototype pollution při rekurzivním slučování

V JavaScriptu dědí běžné objekty z Object.prototype. Pokud kód zapíše vlastnost přímo do tohoto prototypu, objeví se ve všech objektech aplikace. Typický zdroj je rekurzivní slučování (deep merge) nastavení nebo těla požadavku, které prochází klíče vstupu a zapisuje je do cíle, včetně klíče __proto__.

Chybně — Node.js 25, node zranitelne.mjs
function merge(cil, zdroj) {
  for (const klic in zdroj) {
    if (typeof zdroj[klic] === "object" && zdroj[klic] !== null) {
      if (typeof cil[klic] !== "object" || cil[klic] === null) cil[klic] = {};
      merge(cil[klic], zdroj[klic]);
    } else {
      cil[klic] = zdroj[klic];
    }
  }
  return cil;
}

const nastaveni = { jazyk: "cs" };
const telo = JSON.parse('{"jazyk":"en","__proto__":{"admin":true}}');
merge(nastaveni, telo);

const uzivatel = {};
console.log(uzivatel.admin);            // true — každý nový objekt
console.log(Object.prototype.admin);    // true

Výraz cil["__proto__"] nevrací vlastní položku, ale prototyp cíle, tedy Object.prototype. Rekurze pak zapíše admin: true do něj. Kód typu if (uzivatel.admin) nebo výchozí hodnoty čtené z objektu možností pak vidí hodnotu útočníka. Podle toho, kde aplikace vlastnost čte, vede znečištění k obejití kontroly, pádu serveru nebo spuštění kódu. Stejnou cestou může vést i constructor.prototype, pokud slučovací funkce sestupuje do funkcí.

Správně — povolené klíče, objekty bez prototypu, Map
const ZAKAZANE = new Set(["__proto__", "constructor", "prototype"]);

function merge(cil, zdroj) {
  for (const klic of Object.keys(zdroj)) {          // jen vlastní klíče
    if (ZAKAZANE.has(klic)) throw new Error(`Nepovolený klíč ${klic}`);
    const hodnota = zdroj[klic];
    if (typeof hodnota === "object" && hodnota !== null && !Array.isArray(hodnota)) {
      if (!Object.hasOwn(cil, klic) || typeof cil[klic] !== "object" || cil[klic] === null)
        cil[klic] = Object.create(null);             // objekt bez prototypu
      merge(cil[klic], hodnota);
    } else {
      cil[klic] = hodnota;
    }
  }
  return cil;
}

try {
  merge({ jazyk: "cs" }, JSON.parse('{"jazyk":"en","__proto__":{"admin":true}}'));
} catch (e) { console.log(e.message); }                       // Nepovolený klíč __proto__
try {
  merge({}, JSON.parse('{"constructor":{"prototype":{"admin":true}}}'));
} catch (e) { console.log(e.message); }                       // Nepovolený klíč constructor
console.log({}.admin);                                         // undefined

const slovnik = Object.create(null);
slovnik["__proto__"] = { admin: true };
console.log(Object.keys(slovnik), {}.admin);                   // [ '__proto__' ] undefined
const mapa = new Map(Object.entries(JSON.parse('{"__proto__":1}')));
console.log(mapa.get("__proto__"));                            // 1
  • Odmítni nebezpečné klíče __proto__, constructor a prototype a procházej jen vlastní klíče (Object.keys, ne for…in).
  • Slovníky s klíči od uživatele ukládej do Map nebo do objektu z Object.create(null). Ten nemá prototyp, takže __proto__ je u něj obyčejný klíč.
  • Schéma s additionalProperties: false (viz klíčová slova JSON Schema) odmítne nečekané klíče ještě před slučováním. Lepší než slučovat celý vstup je i zde vybrat povolená pole, viz příklad níže.
  • Knihovny pro merge, klonování a nastavení podle cesty (set(obj, "a.b.c", …)) měly v minulosti tuto chybu opakovaně. Aktualizuj je a sleduj bezpečnostní upozornění.
  • Node.js s volbou --disable-proto=delete odstraní přístupový __proto__ z Object.prototype. Ukázka výše pak vypíše undefined. Je to doplňková ochrana a nepomůže proti cestě přes constructor.prototype.

Samotný JSON.parse prototyp nemění; vytvoří obyčejnou vlastní položku __proto__. Zranitelnost vzniká až v kódu, který s výsledkem dál pracuje. Jiné jazyky (Python, PHP, Java) prototypovou dědičnost nemají, obdobné riziko u nich představuje mass assignment, tedy přenesení nečekaných polí do modelu, viz $fillable v Laravelu.

Zdroje, duplicity a HTML

Nastav limit bajtů, hloubky, počtu položek a délky řetězců už na vstupu. Duplicitní klíče mohou různé parsery vyhodnotit jinak; pro citlivé protokoly je odmítej před ztrátou informace. Řetězec z JSON není bezpečné HTML, SQL ani shell argument. Použij bezpečné API pro konkrétní výstup.

Praktické ověření

Testuj nečekaná pole, pole místo objektu, null, velké hodnoty a odmítnutí nepovolených operací. Formátování JSON ani rozkódování tokenu neověřuje podpis. Při podepisování záleží i na dohodnuté serializaci a algoritmu.

Celý příklad výběru povolených polí

Node.js — explicitní model bez slučování cizích klíčů
function readUser(text) {
  if (Buffer.byteLength(text, "utf8") > 4096) throw new Error("Limit vstupu");
  const data = JSON.parse(text);
  if (data === null || typeof data !== "object" || Array.isArray(data))
    throw new Error("Očekáván objekt");
  const keys = Object.keys(data);
  if (keys.some(key => key !== "name")) throw new Error("Nepovolené pole");
  if (!Object.hasOwn(data, "name") || typeof data.name !== "string" || data.name.length > 100)
    throw new Error("Neplatné jméno");
  return {name: data.name};
}
console.log(readUser('{"name":"Eva"}'));
try { readUser('{"name":"Eva","__proto__":{"admin":true}}'); }
catch (error) { console.log(error.message); }

První vstup vrátí objekt name=Eva. Druhý se odmítne jako nepovolené pole. Serverová role není vůbec součástí modelu, takže ji klient nemůže přenést do aktualizace. Buffer limit zde kontroluje již dostupný řetězec; na HTTP hranici musí existovat i limit při čtení bajtů, aby se obří tělo nejdřív celé nealokovalo.

JSON Schema může vyjádřit pravidla typu, délky a dalších vlastností. Výběr a konstrukce nového doménového objektu pak chrání navazující kód před neomezeným merge. Duplicitní klíče už po JSON.parse nemusí být rozpoznatelné; jejich zákaz musí zajišťovat parser/textová vrstva. Text z JSON předávej do DOM přes textContent, do SQL přes parametry a do shellu přes bezpečné argumentové API, nikoli interpolací.

Co zkontrolovat u deserializace a podpisů

Stanov maximum bajtů, hloubky, počtu položek a délky hodnot. Hluboké objekty mohou zatížit následný rekurzivní kód i tehdy, když parser text přijal. Neaktivuj deserializační režim, který podle nedůvěryhodného názvu typu vytváří libovolné aplikační třídy. To je vlastnost knihovny a konfigurace, nikoli povolená schopnost samotného JSON.

U podepisování se oba systémy musejí shodnout na bajtech nebo přesném kanonizačním profilu. Přeskupení klíčů může zachovat JSON hodnotu a změnit podpis textu. Pro webové tokeny použij JWS/JWT knihovnu; neověřuj důvěru samotným dekódováním Base64url. Limity datového a schémového procesoru testuj odděleně od autorizace.