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.

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: nosniffi ściśle ustawiajContent-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
patternw JSON Schema imatch()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+2028iU+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.