Как данные становятся кодом: граница между текстом и разметкой
В безопасности есть класс уязвимостей, где данные перестают быть данными — и начинают исполняться как код. В базах это SQL-инъекция: строка превращается в команду, которая меняет таблицы или читает чужие данные. В браузере похожая история: текст становится частью разметки, а потом — исполняемым скриптом. Это называется межсайтовый скриптинг, или XSS.
Механизм один и тот же: граница между данными и кодом смещается, потому что данные попадают в контекст, где они интерпретируются не как текст, а как инструкция. В базе это строка в SQL-запросе; в браузере — строка в HTML, атрибуте, адресе или скрипте. Разница только в цели: в базе атакующий получает доступ к данным, в браузере — к сессии пользователя, его кукам, локальному хранилищу, или может подменить содержимое страницы.
Вот как это работает.
Три пути на страницу: сохранённый, отражённый и DOM-based
XSS бывает трёх видов — по тому, как вредоносный скрипт попадает на страницу.
Первый — сохранённый. Данные приходят на сервер, сохраняются в базе, а потом выводятся на страницу. Например, комментарий на форуме: пользователь пишет <script>alert(1)</script>, сервер сохраняет, а когда другой пользователь открывает страницу, браузер исполняет этот скрипт. Здесь атакующий не взаимодействует с жертвой напрямую: скрипт живёт на сервере и срабатывает при каждом запросе страницы.
Второй — отражённый. Данные не сохраняются на сервере, а сразу возвращаются в ответе. Например, поиск на сайте: строка из URL подставляется в разметку без экранирования. Если передать в параметре <script>alert(1)</script>, браузер исполнит его при загрузке страницы. Здесь атакующий должен заманить жертву на специально сформированную ссылку — например, через фишинг.
Третий — DOM-based. Скрипт не попадает на сервер вообще: он формируется в браузере из данных, которые уже есть на странице. Например, адрес из location.hash подставляется в innerHTML без проверки. Атакующий отправляет жертве ссылку с #<script>alert(1)</script>, и браузер исполняет скрипт при загрузке страницы.
В первом случае уязвимость в серверном коде, во втором — в серверном или клиентском, в третьем — только в клиентском. Но во всех трёх случаях браузер исполняет скрипт, потому что данные попали в разметку без экранирования.
Почему экранировать нужно при выводе, а не при вводе
Интуитивно кажется, что опасные символы нужно фильтровать при вводе: удалять <, >, ", ', /. Но это неверный подход — по трём причинам.
Первая: данные могут использоваться в разных контекстах. Например, строка <script>alert(1)</script> опасна, если попадает в тело HTML, но безопасна, если сохраняется в базе и потом выводится в текстовом поле или атрибуте. Если экранировать при вводе, то в текстовом поле вместо <script> будет выведено <script>, что не нужно — пользователь увидит мусор.
Вторая: экранирование зависит от контекста. В теле HTML опасны < и >, в атрибуте — ещё и кавычки, в адресе — двоеточие и слэши, внутри скрипта — кавычки и точки с запятой, внутри стиля — точки с запятой и фигурные скобки. Нет универсального набора символов, который нужно экранировать везде.
Третья: данные могут приходить не только от пользователя. Например, API может возвращать строку с HTML-тегами, которую нужно вставить в разметку. Если экранировать при вводе, то эти теги пропадут — а они могут быть нужны.
Поэтому экранировать нужно при выводе — и в зависимости от того, куда данные попадают. В современных каркасах это делается автоматически: например, в React данные в JSX экранируются по умолчанию, в Angular — через {{ }}, в Vue — через {{ }} или v-html с оговорками. Но даже в каркасах есть дыры.
Где каркасы ошибаются: разметка, адреса, события
Каркасы экранируют данные по умолчанию, но не везде. Вот три места, где они пропускают XSS.
Первое — вставка готовой разметки. Например, innerHTML, dangerouslySetInnerHTML в React, v-html в Vue, ng-bind-html в Angular. Если данные содержат скрипты, они исполнятся. Каркасы не могут экранировать HTML, потому что он может быть валидным — например, если пользователь пишет комментарий с форматированием.
Второе — адреса из данных. Например, <a href="{{userInput}}">. Если пользователь передаст javascript:alert(1), браузер исполнит скрипт при клике. Каркасы не экранируют адреса, потому что они могут быть валидными — например, https://example.com. Но javascript: — это не адрес, а скрипт.
Третье — атрибуты событий. Например, <div onclick="{{userInput}}">. Если пользователь передаст alert(1), браузер исполнит скрипт при клике. Каркасы не экранируют атрибуты событий, потому что они могут содержать выражения — например, handleClick().
В этих трёх местах каркасы не защищают, потому что не могут отличить данные от кода. Поэтому разработчик должен проверять данные вручную: например, разрешать только http: и https: в адресах, или использовать textContent вместо innerHTML.
Политика содержимого: вторая линия обороны
Даже если на странице есть уязвимость, браузер может её заблокировать — с помощью политики содержимого, или CSP. Это заголовок, который говорит браузеру, откуда можно загружать скрипты, стили, изображения и другие ресурсы.
Например, заголовок Content-Security-Policy: script-src 'self' разрешает скрипты только с того же домена. Если на странице есть уязвимость, но атакующий пытается загрузить скрипт с чужого домена, браузер его заблокирует.
CSP — это не панацея. Она не защищает от DOM-based XSS, потому что скрипт уже на странице. Она не защищает от сохранённого XSS, если скрипт загружается с того же домена. Но она останавливает отражённый XSS, если скрипт загружается с чужого домена — например, через src="https://evil.com/script.js".
Кроме того, CSP может блокировать inline-скрипты — те, что написаны прямо в разметке. Для этого нужно указать script-src 'self' 'unsafe-inline', но 'unsafe-inline' — это дыра: если на странице есть уязвимость, inline-скрипт исполнится. Лучше вынести все скрипты в отдельные файлы и указать script-src 'self'.
CSP — это вторая линия обороны. Она не заменяет экранирование, но без неё одна ошибка может стоить всей страницы.
Cookie, недоступная скриптам: что закрывает и чего не закрывает
Ещё одна защита — это флаг HttpOnly для кук. Он говорит браузеру, что куку нельзя читать через JavaScript — только отправлять на сервер. Это закрывает одну из главных целей XSS: кражу сессии.
Например, если на странице есть уязвимость, атакующий может исполнить скрипт, который читает куку сессии и отправляет её на свой сервер. Но если кука помечена как HttpOnly, скрипт её не увидит.
Но HttpOnly не защищает от других атак. Например, атакующий может подменить содержимое страницы, чтобы заманить пользователя ввести логин и пароль на поддельную форму. Или может прочитать локальное хранилище, если оно используется для сессии. Или может отправить запросы от имени пользователя — например, через CSRF.
Поэтому HttpOnly — это не защита от XSS, а защита от одной из его целей. Она не заменяет экранирование и CSP.
Как проверить свой код за час: где искать уязвимости
Чтобы найти XSS в своём коде, нужно искать три вещи: вставку разметки, построение адресов из данных и атрибуты событий.
Первое — вставка разметки. Ищите innerHTML, dangerouslySetInnerHTML, v-html, ng-bind-html, insertAdjacentHTML, document.write. Если данные попадают туда без проверки, это уязвимость.
Второе — построение адресов из данных. Ищите <a href="{{userInput}}">, <img src="{{userInput}}">, <iframe src="{{userInput}}">. Если данные попадают в адрес без проверки, это уязвимость.
Третье — атрибуты событий. Ищите <div onclick="{{userInput}}">, <button onmouseover="{{userInput}}">. Если данные попадают в атрибут события без проверки, это уязвимость.
Кроме того, ищите места, где данные попадают в разметку через шаблоны — например, {{userInput}} в Angular или {{userInput}} в Vue. Если каркас не экранирует данные по умолчанию, это уязвимость.
Если найдёте такие места, проверьте, откуда берутся данные: от пользователя, API, или это константы. Если от пользователя или API, добавьте проверку: например, разрешайте только http: и https: в адресах, или используйте textContent вместо innerHTML.
Тупик: почему фильтровать опасные слова на входе — не выход
Первая идея, которая приходит в голову: фильтровать опасные слова на входе — например, удалять <script>, javascript:, onerror=. Но это не работает — по трём причинам.
Первая: атакующий может обойти фильтр. Например, вместо <script> написать <scr<script>ipt>, вместо javascript: — java\tscript:, вместо onerror= — onerror =. Фильтры легко обходятся, если знать, как они устроены.
Вторая: фильтры ломают валидные данные. Например, если пользователь пишет комментарий с кодом, фильтр удалит все < и >, и код станет нечитаемым. Или если пользователь пишет адрес с javascript: в комментарии, фильтр удалит его — а это может быть валидным примером.
Третья: фильтры не учитывают контекст. Например, <script> опасен в теле HTML, но безопасен в текстовом поле. Фильтр удалит его везде — и сломает валидные данные.
Поэтому фильтровать опасные слова на входе — это тупик. Лучше экранировать данные при выводе — и в зависимости от контекста.
Позиция редакции
XSS — это не баг в браузере, а ошибка в коде. Браузер исполняет скрипты, потому что разработчик разрешил: не экранировал данные, не проверил адреса, не настроил CSP. Поэтому защита от XSS — это не настройка браузера, а настройка кода.
Экранировать нужно при выводе, а не при вводе — потому что данные могут использоваться в разных контекстах, и экранирование зависит от контекста. Фильтровать опасные слова на входе — это тупик: фильтры легко обходятся и ломают валидные данные.
CSP и HttpOnly — это вторая линия обороны. Они не заменяют экранирование, но без них одна ошибка может стоить всей страницы.
Условие, при котором эта позиция неверна: если данные никогда не попадают в разметку — например, если приложение не использует браузер, а только API. Но в вебе это редкость: даже если приложение не выводит данные на страницу, оно может использовать их в адресах или атрибутах событий. Поэтому экранирование и CSP нужны почти всегда.