JSON Toolkit

Lista di controllo dei rischi di sicurezza nell'elaborazione JSON

In sintesi: JSON.parse stesso è sicuro; il pericolo è ciò che accade dopo: deep merge, assegnazione per percorso, incorporamento in HTML, profondità e dimensione illimitate.

Intermedio~12 min di letturaPubblicato il 2026-10-30Aggiornato il 2026-10-30
Copertina della lista di controllo dei rischi di sicurezza nell'elaborazione JSON

In sintesi: chiudi prima i due punti di ingresso — ogni deep merge e assegnazione per percorso deve rifiutare proto, prototype e constructor, e la dimensione del corpo della richiesta più la profondità di annidamento necessitano limiti rigidi che si applichino prima del parsing. Sorvegliare JSON.parse stesso lascia le porte pericolose spalancate.

Gli attaccanti non hanno bisogno di inventare una nuova vulnerabilità; aggiungono solo un'altra chiave al JSON.

1. Inquinamento del prototipo: il più comune e il più trascurato

1.1 JSON.parse stesso è sicuro

const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted);        // undefined
console.log({}.polluted);          // undefined

JSON.parse crea __proto__ come una normale proprietà propria e non riscrive la catena di prototipi. Quindi l'affermazione che "JSON.parse causa l'inquinamento del prototipo" è imprecisa.

1.2 Il merge che segue è ciò che è pericoloso

/**
 * 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 — the entire site is polluted

constructor.prototype è un altro punto di ingresso dello stesso tipo:

{ "constructor": { "prototype": { "isAdmin": true } } }

1.3 Come correggerlo

/** 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;
}

Un approccio più completo è usare Object.create(null) come contenitore (non c'è catena di prototipi da inquinare), o semplicemente una Map. Quando usi una libreria pronta, conferma che gestisca già queste tre chiavi — molte versioni più vecchie di merge di lodash sono state segnalate esattamente per questo.

1.4 L'assegnazione per percorso è ugualmente pericolosa

// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

Quando __proto__ compare in pathSegments, questo è equivalente all'inquinamento sopra. Le implementazioni di JSON Pointer / JSON Patch devono validare ogni segmento del token.

2. Attacchi di esaurimento delle risorse

Attacco Tattica Mitigazione
Annidamento profondo Centomila livelli di [[[[...]]]] Limita la profondità di annidamento (di solito 32–64) e pre-scansiona prima del parsing
Array sovradimensionati Un array con decine di milioni di elementi Limita la dimensione del corpo della richiesta e la lunghezza degli array
Stringhe estremamente lunghe Una singola stringa di centinaia di MB Limita la lunghezza dei campi
Esplosione di chiavi duplicate Un numero enorme di chiavi duplicate Limita il numero di chiavi dell'oggetto

Le due difese più efficaci sul server sono i limiti di dimensione del corpo della richiesta e i limiti di profondità, e entrambi devono entrare in vigore prima che avvenga qualsiasi parsing.

/**
 * 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. Iniezione durante l'incorporamento in HTML

Scrivere JSON direttamente in una pagina è una pratica comune, ma introduce tre classi di problemi:

<script>
  window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>

Il </script> dentro la stringa chiude presto il blocco script, e il resto viene analizzato come HTML — un classico XSS.

Altri due caratteri si comportano allo stesso modo: \u2028 (separatore di riga) e \u2029 (separatore di paragrafo) sono legali in JSON, ma incorporarli nel sorgente JS innesca un errore di sintassi.

Correzione:

/**
 * 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');
}

Un approccio più robusto è mettere i dati dentro <script type="application/json"> (il cui contenuto non viene eseguito), poi leggerli con JSON.parse(document.getElementById('data').textContent).

4. Dirottamento JSON e JSONP

I primi browser consentivano a un <script src="https://api.example.com/user"> cross-origin di eseguire direttamente il JavaScript restituito, e il JSON il cui livello superiore è un array è per caso anche un'espressione JS valida, quindi un attaccante poteva leggere i dati sovrascrivendo il costruttore Array.

I browser moderni hanno già protezioni, ma raccomandiamo comunque:

  • Usa un oggetto invece di un array al livello superiore dell'API ({"data": [...]});
  • Invia X-Content-Type-Options: nosniff e imposta rigorosamente Content-Type: application/json;
  • Smetti di usare JSONP; usa CORS quando l'accesso cross-origin è davvero necessario.

5. Confusione di tipo durante la deserializzazione in oggetti

Una volta abilitata la tipizzazione predefinita in Jackson (enableDefaultTyping), un attaccante può specificare un campo @class per far istanziare al server una classe arbitraria, e combinarlo con una catena di gadget sfruttabile per ottenere l'esecuzione di codice in remoto. Vulnerabilità di questo tipo sono comparse molte volte (sia Fastjson sia Jackson sono stati colpiti).

Principio: il tipo di destinazione della deserializzazione deve essere specificato staticamente dal codice del server e non deve mai essere determinato dal contenuto del payload.

6. Denial of service in espressioni regolari e Schema

  • Il pattern di JSON Schema e la match() di JSONPath accettano entrambi espressioni regolari;
  • Pattern come (a+)+$ fanno backtracking esponenziale su input lunghi;
  • Se la regex viene dagli utenti (regole di validazione personalizzate), devi limitarne la lunghezza, vietare i backreference e i quantificatori annidati, o passare a un motore lineare come RE2.

7. Logging e mascheramento

I log JSON sono il modo più semplice per far trapelare informazioni sensibili:

/** 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;
}

Nota che il mascheramento deve avvenire prima che il log venga scritto, non ripulendo la piattaforma di logging dopo.

8. Lista di controllo prima del rilascio

  • [ ] Tutte le funzioni di deep merge rifiutano __proto__ / prototype / constructor;
  • [ ] I token di percorso di JSON Pointer / Patch sono validati contro una allowlist;
  • [ ] La dimensione del corpo della richiesta e la profondità di annidamento hanno limiti rigidi, e quei limiti si applicano prima del parsing;
  • [ ] Il JSON incorporato in HTML fa escape di <, >, &, U+2028 e U+2029;
  • [ ] Le API usano un oggetto al livello superiore e le risposte inviano nosniff;
  • [ ] I tipi di destinazione della deserializzazione sono specificati staticamente nel codice;
  • [ ] Le espressioni regolari controllate dall'utente hanno limiti di complessità;
  • [ ] I campi dei log hanno regole di mascheramento e quelle regole sono testate.

FAQ

Come verifico se il mio deep merge è vulnerabile all'inquinamento del prototipo?

Passa JSON.parse('{"__proto__": {"isAdmin": true}}') nella funzione di merge, poi controlla se {}.isAdmin è diventato true; ripeti con un payload constructor.prototype. Con una libreria di terze parti, conferma che rifiuti __proto__, prototype e constructor — molte versioni più vecchie di merge di lodash sono state segnalate esattamente per questo. L'assegnazione basata su percorso necessita di un proprio controllo, perché un token __proto__ nel percorso inquina allo stesso modo.

Perché JSON.parse stesso non è la fonte dell'inquinamento del prototipo?

Crea __proto__ come una normale proprietà propria e non riscrive mai la catena di prototipi, quindi sia data.polluted sia {}.polluted restano undefined. L'inquinamento avviene dopo: un deep merge non sicuro scrive le chiavi dell'oggetto sorgente direttamente nell'oggetto destinazione, e constructor.prototype è un punto di ingresso dello stesso tipo. Trattare JSON.parse come causa radice significa correggere il posto sbagliato.

Il JSON incorporato in HTML dovrebbe essere sempre sottoposto a escape?

Sì. Un </script> dentro una stringa chiude presto il blocco script e il resto viene analizzato come HTML — un classico XSS; anche <, >, &, U+2028 e U+2029 necessitano di escape. Un'opzione più sicura è mettere i dati dentro <script type="application/json">, il cui contenuto non viene eseguito, e leggerli con JSON.parse su textContent.