Как через JSON происходит XSS
Главное: 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; // parses markup
el.textContent = user.bio; // inserts text
{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, разбирается идентично и больше не может закрыть тег.