JSON Toolkit

Sicherheitsrisiko-Checkliste für die JSON-Verarbeitung

Fazit: JSON.parse selbst ist sicher; die Gefahr ist, was danach passiert: tiefes Zusammenführen, Zuweisung nach Pfad, Inlining in HTML, unbegrenzte Tiefe und Größe.

Fortgeschritten~12 Min. LesezeitVeröffentlicht 2026-10-30Aktualisiert 2026-10-30
Titelbild für Sicherheitsrisiko-Checkliste für die JSON-Verarbeitung

Kurz gesagt: Schließen Sie zuerst die beiden Einstiegspunkte – jedes tiefe Zusammenführen und jede Pfadzuweisung muss proto, prototype und constructor ablehnen, und für Anfragegröße sowie Verschachtelungstiefe braucht es harte Grenzen, die vor dem Parsen gelten. JSON.parse selbst zu beobachten lässt die gefährlichen Türen weit offen.

Angreifer müssen keine neue Schwachstelle erfinden; sie fügen dem JSON nur einen weiteren Schlüssel hinzu.

1. Prototype Pollution: am verbreitetsten und am meisten übersehen

1.1 JSON.parse selbst ist sicher

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

JSON.parse erzeugt __proto__ als gewöhnliche eigene Eigenschaft und schreibt die Prototypenkette nicht um. Die Behauptung „JSON.parse verursacht Prototype Pollution“ ist also ungenau.

1.2 Das anschließende Merge ist das Gefährliche

/**
 * 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 ist ein anderer Einstiegspunkt derselben Art:

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

1.3 Wie man es behebt

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

Ein gründlicherer Ansatz ist, Object.create(null) als Container zu verwenden (es gibt keine Prototypenkette, die man verschmutzen könnte) oder einfach eine Map. Bei einer fertigen Bibliothek bestätigen Sie, dass sie diese drei Schlüssel bereits behandelt – viele ältere Versionen von lodash merge wurden genau dafür gemeldet.

1.4 Zuweisung nach Pfad ist ebenso gefährlich

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

Taucht __proto__ in pathSegments auf, entspricht das der obigen Verschmutzung. Implementierungen von JSON Pointer / JSON Patch müssen jedes Token-Segment validieren.

2. Angriffe zur Ressourcenerschöpfung

Angriff Taktik Gegenmaßnahme
Tiefe Verschachtelung Hunderttausend Ebenen [[[[...]]]] Verschachtelungstiefe begrenzen (meist 32–64) und vor dem Parsen vorab scannen
Überdimensionierte Arrays Ein Array mit zig Millionen Elementen Anfragegröße und Array-Länge begrenzen
Extrem lange Strings Ein einzelner String von Hunderten MB Feldlänge begrenzen
Explosion doppelter Schlüssel Eine riesige Zahl doppelter Schlüssel Zahl der Objektschlüssel begrenzen

Die zwei wirksamsten Verteidigungen auf dem Server sind Limits für die Anfragegröße und Tiefenlimits, und beide müssen wirksam werden, bevor irgendein Parsen geschieht.

/**
 * 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. Injection beim Inlining in HTML

JSON direkt in eine Seite zu schreiben ist gängige Praxis, bringt aber drei Problemklassen mit:

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

Das </script> innerhalb des Strings schließt den Script-Block vorzeitig, und der Rest wird als HTML geparst – ein klassisches XSS.

Zwei weitere Zeichen verhalten sich gleich: \u2028 (Zeilentrenner) und \u2029 (Absatztrenner) sind in JSON zulässig, aber in JS-Quelltext eingebettet lösen sie einen Syntaxfehler aus.

Behebung:

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

Ein robusterer Ansatz ist, die Daten in <script type="application/json"> zu legen (dessen Inhalt nicht ausgeführt wird) und sie mit JSON.parse(document.getElementById('data').textContent) zu lesen.

4. JSON-Hijacking und JSONP

Frühe Browser erlaubten einem cross-origin <script src="https://api.example.com/user">, das zurückgegebene JavaScript direkt auszuführen, und JSON mit einem Array auf oberster Ebene ist zufällig auch ein gültiger JS-Ausdruck, sodass ein Angreifer die Daten lesen konnte, indem er den Array-Konstruktor überschrieb.

Moderne Browser haben bereits Schutzmaßnahmen, aber wir empfehlen weiterhin:

  • Auf oberster Ebene der API ein Objekt statt eines Arrays verwenden ({"data": [...]});
  • X-Content-Type-Options: nosniff senden und Content-Type: application/json strikt setzen;
  • JSONP nicht mehr verwenden; CORS nutzen, wenn cross-origin Zugriff wirklich nötig ist.

5. Typverwechslung beim Deserialisieren in Objekte

Ist in Jackson Default Typing aktiviert (enableDefaultTyping), kann ein Angreifer ein @class-Feld angeben, um den Server eine beliebige Klasse instanziieren zu lassen, und es mit einer ausnutzbaren Gadget-Kette kombinieren, um Remote Code Execution zu erreichen. Solche Schwachstellen sind mehrfach aufgetreten (sowohl Fastjson als auch Jackson wurden getroffen).

Prinzip: Der Zieltyp der Deserialisierung muss statisch vom Servercode vorgegeben sein und darf niemals durch den Payload-Inhalt bestimmt werden.

6. Denial of Service in regulären Ausdrücken und Schemas

  • Das pattern von JSON Schema und das match() von JSONPath akzeptieren beide reguläre Ausdrücke;
  • Muster wie (a+)+$ backtracken bei langen Eingaben exponentiell;
  • Kommt das Regex von Nutzern (benutzerdefinierte Validierungsregeln), müssen Sie seine Länge begrenzen, Backreferences und verschachtelte Quantoren verbieten oder auf eine lineare Engine wie RE2 wechseln.

7. Logging und Maskierung

JSON-Logs sind der einfachste Weg, sensible Informationen zu leaken:

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

Beachten Sie, dass die Maskierung vor dem Schreiben des Logs geschehen muss, nicht durch nachträgliches Aufräumen der Logging-Plattform.

8. Checkliste vor dem Start

  • [ ] Alle Tiefenvergleichsfunktionen lehnen __proto__ / prototype / constructor ab;
  • [ ] JSON-Pointer-/Patch-Pfad-Token werden gegen eine Whitelist validiert;
  • [ ] Anfragegröße und Verschachtelungstiefe haben harte Limits, und diese Limits gelten vor dem Parsen;
  • [ ] In HTML eingebettetes JSON escaped <, >, &, U+2028 und U+2029;
  • [ ] APIs verwenden ein Objekt auf oberster Ebene, und Antworten senden nosniff;
  • [ ] Deserialisierungs-Zieltypen werden statisch im Code vorgegeben;
  • [ ] Von Nutzern kontrollierte reguläre Ausdrücke haben Komplexitätslimits;
  • [ ] Log-Felder haben Maskierungsregeln, und diese Regeln sind getestet.

FAQ

Wie prüfe ich, ob mein Tiefenvergleich für Prototype Pollution anfällig ist?

Geben Sie JSON.parse('{"__proto__": {"isAdmin": true}}') in die Merge-Funktion und prüfen Sie dann, ob {}.isAdmin zu true geworden ist; wiederholen Sie es mit einem constructor.prototype-Payload. Bei einer Drittanbieter-Bibliothek bestätigen Sie, dass sie __proto__, prototype und constructor ablehnt – viele ältere lodash-merge-Versionen wurden genau dafür gemeldet. Pfadbasierte Zuweisung braucht eine eigene Prüfung, weil ein __proto__-Token im Pfad auf dieselbe Weise verschmutzt.

Warum ist JSON.parse selbst nicht die Quelle der Prototype Pollution?

Es erzeugt __proto__ als gewöhnliche eigene Eigenschaft und schreibt die Prototypenkette nie um, data.polluted und {}.polluted bleiben also beide undefined. Die Verschmutzung geschieht danach: Ein unsicheres tiefes Merge schreibt die Schlüssel des Quellobjekts direkt ins Ziel, und constructor.prototype ist ein Einstiegspunkt derselben Art. JSON.parse als Ursache zu behandeln heißt, die falsche Stelle zu reparieren.

Sollte in HTML eingebettetes JSON immer escaped werden?

Ja. Ein </script> innerhalb eines Strings schließt den Script-Block vorzeitig, und der Rest wird als HTML geparst – ein klassisches XSS; <, >, &, U+2028 und U+2029 müssen ebenfalls escaped werden. Eine sicherere Option ist, die Daten in <script type="application/json"> zu legen, dessen Inhalt nicht ausgeführt wird, und sie mit JSON.parse auf textContent zu lesen.