Jak dochodzi do XSS przez JSON
Podsumowanie: JSON nigdy nie wykonuje się sam. XSS powstaje w momencie, gdy traktujesz go jak HTML — składanie łańcuchów po stronie serwera, we frontendowy innerHTML albo osadzenie JSON-a prosto w tagu skryptu.

Najważniejsze: JSON nigdy nie wykonuje się sam — najpierw zamknij trzy ścieżki wyjścia: escape'uj po stronie serwera według kontekstu, używaj textContent zamiast innerHTML i zastąp < przez \u003c przy osadzaniu JSON.
{"name": "<img src=x onerror=alert(1)>"} to poprawna dana. To, czy jest niebezpieczna, zależy całkowicie od tego, gdzie ją umieścisz.
Wejście pierwsze: budowanie łańcuchów po stronie serwera
res.send(`<div>Hello, ${data.name}</div>`);
Przeglądarka parsuje znaczniki wewnątrz data.name. JSON jest tu tylko kontenerem; winą jest ucieczka dla złego kontekstu.
Poprawka: użyj domyślnej ucieczki silnika szablonów (<%= %> w EJS, {{ }} w Vue i Django). Unikaj <%- %>, v-html i |safe dla czegokolwiek kontrolowanego przez użytkownika.
Wejście drugie: innerHTML
el.innerHTML = user.bio; // parses markup
el.textContent = user.bio; // inserts text
{user.bio} w React i {{ user.bio }} w Vue tworzą węzły tekstowe i są domyślnie bezpieczne. dangerouslySetInnerHTML i v-html to jawne rezygnacje.
Jeśli naprawdę potrzebujesz tekstu sformatowanego, sanityzuj z białą listą (DOMPurify i podobne), zamiast ufać backendowi.
Wejście trzecie: osadzenie w tagu skryptu
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
</script> wewnątrz danych zamyka blok wcześniej, a wszystko po nim działa jako nowy skrypt. To najbardziej przeoczany przypadek.
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
Nadal poprawny JSON, identyczny po parsowaniu, nie potrafi już zamknąć tagu. Next.js i Nuxt robią to samo dla __NEXT_DATA__ i __NUXT__.
Dwaj sąsiedzi warci poznania
JSON hijacking — stare przeglądarki wykonywały JSON ładowany przez <script src>; przedefiniowanie Array ujawniało zawartość. Współczesne przeglądarki to naprawiły, a Content-Type: application/json plus X-Content-Type-Options: nosniff zamyka resztę.
Pozostałości JSONP — nazwa callbacku kontrolowana przez użytkownika to z założenia wykonanie dowolnego kodu. Nie używaj tego w nowych systemach.
Lista kontrolna
- Każde miejsce, w którym pole JSON dociera do HTML, jest escape'owane według kontekstu (HTML, atrybut, JS, URL)
- Żadnego
innerHTML,v-htmlanidangerouslySetInnerHTMLkonsumującego łańcuchy z API - Osadzony JSON escape'uje
<,>i& - Tekst sformatowany przechodzi sanitację białą listą, a nie filtrowanie czarną listą
- Odpowiedzi niosą
Content-Type: application/jsoninosniff
FAQ
Dlaczego poprawny JSON nadal może wywołać XSS?
JSON to tylko tekst i nigdy się nie wykonuje; bycie poprawnym znaczy jedynie, że się parsuje. Niebezpieczeństwo pojawia się, gdy jest zapisywany w kontekście HTML lub skryptu: przeglądarka parsuje znaczniki albo </script> zamyka blok wcześniej i reszta działa jako kod. Winą jest brak ucieczki dostosowanej do kontekstu przy wyjściu, a nie format danych.
Czy użyć textContent czy innerHTML do renderowania pól API?
Preferuj textContent: wstawia wartość jako zwykły tekst, więc znaczniki są wyświetlane dosłownie. innerHTML parsuje znaczniki, więc pole API zawierające <img onerror=...> się wykona. {user.bio} w React i {{ user.bio }} w Vue domyślnie tworzą węzły tekstowe; dangerouslySetInnerHTML i v-html to jawne rezygnacje.
Jak escape'ować JSON osadzony w tagu <script>?
Tak. </script> wewnątrz danych zamyka blok wcześniej i wszystko po nim działa jako nowy skrypt. Po serializacji zastąp <, > i & przez \u003c, \u003e i \u0026: wynik nadal jest poprawnym JSON-em, parsuje się identycznie i nie potrafi już zamknąć tagu.