Як XSS трапляється через JSON
Головне: JSON ніколи не виконується сам. XSS трапляється в момент, коли ви трактуєте його як HTML — склеювання рядків на сервері, `innerHTML` на фронтенді або вбудовування JSON просто в тег скрипта.

Коротко: JSON ніколи не виконується сам — спершу закрийте три шляхи виводу: екрануйте на сервері за контекстом, використовуйте textContent замість innerHTML і замінюйте < на \u003c під час вбудовування JSON.
{"name": "<img src=x onerror=alert(1)>"} — валідні дані. Чи небезпечні вони, цілком залежить від того, куди ви їх покладете.
Вхід перший: складання рядків на сервері
res.send(`<div>Hello, ${data.name}</div>`);
Браузер розбирає теги всередині data.name. JSON тут лише контейнер; провина — екранування для неправильного контексту.
Виправлення: використовуйте типове екранування рушія шаблонів (<%= %> в EJS, {{ }} у Vue й Django). Уникайте <%- %>, v-html і |safe для всього контрольованого користувачем.
Вхід другий: innerHTML
el.innerHTML = user.bio; // розбирає розмітку
el.textContent = user.bio; // вставляє текст
{user.bio} у React і {{ user.bio }} у Vue створюють текстові вузли й безпечні за замовчуванням. dangerouslySetInnerHTML і v-html — явні відмови від цього.
Якщо вам справді потрібен багатий текст, санітизуйте за білим списком (DOMPurify та подібні), а не довіряйте бекенду.
Вхід третій: вбудовування в тег скрипта
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
</script> усередині даних передчасно закриває блок, і все після нього виконується як новий скрипт. Це найбільш проґавлений випадок.
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
Усе ще валідний JSON, ідентичний після розбору, більше не може закрити тег. Next.js і Nuxt роблять те саме для __NEXT_DATA__ і __NUXT__.
Два сусіди, варті знання
Викрадення JSON — старі браузери виконували JSON, завантажений через <script src>; перевизначення Array зливало вміст. Сучасні браузери це виправили, а Content-Type: application/json плюс X-Content-Type-Options: nosniff закривають решту.
Залишки JSONP — контрольована користувачем назва зворотного виклику за задумом є виконанням довільного коду. Не використовуйте його в нових системах.
Чек-лист
- Кожне місце, де поле JSON досягає HTML, екранується для свого контексту (HTML, атрибут, JS, URL)
- Жоден
innerHTML,v-htmlчиdangerouslySetInnerHTMLне споживає рядки API - Вбудований JSON екранує
<,>і& - Багатий текст проходить санітизацію за білим списком, а не фільтрацію за чорним
- Відповіді несуть
Content-Type: application/jsonіnosniff
Часті питання
Чому валідний JSON усе ще може спричинити XSS?
JSON — лише текст і ніколи не виконується; бути валідним означає лише розбиратися. Небезпека зʼявляється, коли його пишуть у контекст HTML або скрипта: браузер розбирає теги, або </script> передчасно закриває блок, і решта виконується як код. Провина — відсутнє контекстно-залежне екранування під час виводу, а не формат даних.
Використовувати textContent чи innerHTML для відображення полів API?
Надавайте перевагу textContent: він вставляє значення як звичайний текст, тож теги показуються буквально. innerHTML розбирає розмітку, тож поле API, що містить <img onerror=...>, виконується. {user.bio} у React і {{ user.bio }} у Vue типово створюють текстові вузли; dangerouslySetInnerHTML і v-html — явні відмови.
Як екранувати JSON, вбудований у тег <script>?
Так. </script> усередині даних передчасно закриває блок, і все після нього виконується як новий скрипт. Після серіалізації замініть <, > і & на \u003c, \u003e і \u0026: результат усе ще валідний JSON, розбирається ідентично й більше не може закрити тег.