JSON Toolkit

Lista kontrolna ryzyka bezpieczeństwa przy przetwarzaniu JSON

Podsumowanie: Sam JSON.parse jest bezpieczny; niebezpieczeństwo to co dzieje się po nim: głębokie scalanie, przypisywanie po ścieżce, osadzanie w HTML, brak limitów głębokości i rozmiaru.

Średniozaawansowany~12 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka artykułu o liście kontrolnej ryzyka bezpieczeństwa przy przetwarzaniu JSON

Najważniejsze: najpierw zamknij dwa punkty wejścia — każde głębokie scalanie i przypisanie po ścieżce musi odrzucać proto, prototype i constructor, a rozmiar treści żądania oraz głębokość zagnieżdżenia wymagają twardych limitów obowiązujących przed parsowaniem. Pilnowanie samego JSON.parse pozostawia groźne drzwi szeroko otwarte.

Atakujący nie musi wymyślać nowej podatności; wystarczy, że doda jeszcze jeden klucz do JSON-a.

1. Zanieczyszczenie prototypu: najczęstsze i najbardziej przeoczone

1.1 Sam JSON.parse jest bezpieczny

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

JSON.parse tworzy __proto__ jako zwykłą właściwość własną i nie przepisuje łańcucha prototypów. Więc twierdzenie, że „JSON.parse powoduje zanieczyszczenie prototypu”, jest nieścisłe.

1.2 Niebezpieczne jest scalanie, które następuje później

/**
 * 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 to drugi punkt wejścia tego samego rodzaju:

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

1.3 Jak to naprawić

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

Dokładniejsze podejście to użycie Object.create(null) jako kontenera (nie ma łańcucha prototypów do zanieczyszczenia) albo po prostu Map. Używając gotowej biblioteki, potwierdź, że obsługuje już te trzy klucze — wiele starszych wersji merge w lodash było zgłaszanych właśnie za to.

1.4 Przypisywanie po ścieżce jest równie niebezpieczne

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

Gdy __proto__ pojawi się w pathSegments, jest to równoważne zanieczyszczeniu powyżej. Implementacje JSON Pointer / JSON Patch muszą walidować każdy segment tokenu.

2. Ataki wyczerpania zasobów

Atak Taktyka Łagodzenie
Głębokie zagnieżdżenie Sto tysięcy poziomów [[[[...]]]] Limit głębokości zagnieżdżenia (zwykle 32–64) i wstępne skanowanie przed parsowaniem
Nadmiernie duże tablice Tablica z dziesiątkami milionów elementów Limit rozmiaru treści żądania i długości tablicy
Bardzo długie łańcuchy Pojedynczy łańcuch o setkach MB Limit długości pola
Eksplozja zduplikowanych kluczy Ogromna liczba zduplikowanych kluczy Limit liczby kluczy obiektu

Dwie najskuteczniejsze obrony na serwerze to limity rozmiaru treści żądania i limity głębokości, przy czym oba muszą działać przed jakimkolwiek parsowaniem.

/**
 * 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. Wstrzykiwanie przy osadzaniu w HTML

Zapisywanie JSON bezpośrednio do strony to powszechna praktyka, ale wprowadza trzy klasy problemów:

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

</script> wewnątrz łańcucha zamyka blok skryptu wcześniej, a reszta jest parsowana jako HTML — klasyczny XSS.

Dwa kolejne znaki zachowują się tak samo: \u2028 (separator wierszy) i \u2029 (separator akapitów) są legalne w JSON-ie, ale osadzenie ich w kodzie źródłowym JS wywołuje błąd składni.

Poprawka:

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

Solidniejsze podejście to umieszczenie danych w <script type="application/json"> (którego treść nie jest wykonywana), a następnie odczytanie ich przez JSON.parse(document.getElementById('data').textContent).

4. JSON hijacking i JSONP

Wczesne przeglądarki pozwalały międzyźródłowemu <script src="https://api.example.com/user"> wykonać zwrócony JavaScript bezpośrednio, a JSON, którego wierzchołkiem jest tablica, przypadkiem jest też poprawnym wyrażeniem JS, więc atakujący mógł odczytać dane, nadpisując konstruktor Array.

Współczesne przeglądarki mają już zabezpieczenia, ale nadal zalecamy:

  • Używaj obiektu, a nie tablicy, na wierzchołku API ({"data": [...]});
  • Wysyłaj X-Content-Type-Options: nosniff i ściśle ustawiaj Content-Type: application/json;
  • Przestań używać JSONP; używaj CORS, gdy międzyźródłowy dostęp jest naprawdę potrzebny.

5. Pomieszanie typów przy deserializacji do obiektów

Gdy w Jacksonie włączone jest domyślne typowanie (enableDefaultTyping), atakujący może wskazać pole @class, aby serwer utworzył dowolną klasę, i połączyć to z wykorzystywalnym łańcuchem gadżetów, aby osiągnąć zdalne wykonanie kodu. Podatności tego rodzaju pojawiały się wiele razy (dotknęły zarówno Fastjson, jak i Jackson).

Zasada: docelowy typ deserializacji musi być statycznie określony przez kod serwera i nigdy nie może być wyznaczany przez treść ładunku.

6. Odmowa usługi w wyrażeniach regularnych i schematach

  • pattern w JSON Schema i match() w JSONPath akceptują wyrażenia regularne;
  • Wzorce takie jak (a+)+$ backtrackują wykładniczo na długich wejściach;
  • Jeśli regex pochodzi od użytkowników (niestandardowe reguły walidacji), musisz ograniczyć jego długość, zabronić odwołań wstecznych i zagnieżdżonych kwantyfikatorów albo przejść na silnik liniowy, taki jak RE2.

7. Logowanie i maskowanie

Logi JSON to najłatwiejszy sposób wyciekania informacji wrażliwych:

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

Zauważ, że maskowanie musi następować przed zapisem logu, a nie przez czyszczenie platformy logowania po fakcie.

8. Lista kontrolna przed wdrożeniem

  • [ ] Wszystkie funkcje głębokiego scalania odrzucają __proto__ / prototype / constructor;
  • [ ] Tokeny ścieżek JSON Pointer / Patch są walidowane względem białej listy;
  • [ ] Rozmiar treści żądania i głębokość zagnieżdżenia mają twarde limity, a limity obowiązują przed parsowaniem;
  • [ ] JSON osadzany w HTML escape'uje <, >, &, U+2028 i U+2029;
  • [ ] API używa obiektu na wierzchołku, a odpowiedzi wysyłają nosniff;
  • [ ] Docelowe typy deserializacji są statycznie określone w kodzie;
  • [ ] Regex kontrolowane przez użytkownika mają limity złożoności;
  • [ ] Pola logów mają reguły maskowania, a reguły są testowane.

FAQ

Jak sprawdzić, czy moje głębokie scalanie jest podatne na zanieczyszczenie prototypu?

Podaj JSON.parse('{"__proto__": {"isAdmin": true}}') do funkcji scalającej, a następnie sprawdź, czy {}.isAdmin stało się true; powtórz z ładunkiem constructor.prototype. W przypadku biblioteki zewnętrznej potwierdź, że odrzuca __proto__, prototype i constructor — wiele starszych wersji merge w lodash było zgłaszanych właśnie za to. Przypisywanie po ścieżce wymaga osobnej kontroli, ponieważ token __proto__ w ścieżce zanieczyszcza w ten sam sposób.

Dlaczego sam JSON.parse nie jest źródłem zanieczyszczenia prototypu?

Tworzy __proto__ jako zwykłą właściwość własną i nigdy nie przepisuje łańcucha prototypów, więc zarówno data.polluted, jak i {}.polluted pozostają undefined. Zanieczyszczenie następuje później: niebezpieczne głębokie scalanie wpisuje klucze źródła prosto do celu, a constructor.prototype jest punktem wejścia tego samego rodzaju. Traktowanie JSON.parse jako przyczyny oznacza naprawianie niewłaściwego miejsca.

Czy JSON osadzany w HTML zawsze trzeba escape'ować?

Tak. </script> wewnątrz łańcucha zamyka blok skryptu wcześniej, a reszta jest parsowana jako HTML — klasyczny XSS; <, >, &, U+2028 i U+2029 też wymagają ucieczki. Bezpieczniejszą opcją jest umieszczenie danych w <script type="application/json">, którego treść nie jest wykonywana, i odczytanie ich przez JSON.parse na textContent.