Прямой ответ
Две атаки, которые путают: CSRF — атакующий заставляет ваш браузер отправить запрос от вашего имени на сайт, где вы залогинены (cookie улетают автоматически — «страница-ловушка с формой перевода» — сайт видит легитимную сессию), XSS — атакующий внедряет свой JavaScript в страницу сайта (скрипт работает в контексте жертвы — крадёт данные/токены/делает запросы — CSRF «руками чужого браузера», XSS — «своим кодом в чужой странице» — вторая почти всегда страшнее); защита от CSRF (три слоя, комбинируем): (1) SameSite=Lax на сессионных cookie (дефолт современных браузеров — кросс-сайт POST не берёт cookie — закрывает 90% классики — cookie-механика; но Lax не защищает от кросс-сайт GET и SameSite=None сценариев — не единственная защита); (2) CSRF-токен (synchronizer token: случайный токен в сессии, дублируется в скрытом поле формы/заголовке — сервер сверяет — атакующая страница токен не знает (same-origin не даёт прочитать) — Django делает из коробки ({% csrf_token %}), FastAPI — middleware/зависимость — зависимости); (3) для API с JWT в заголовке — CSRF не нужен (браузер заголовок сам не добавит — атакующая форма шлёт только cookie — «чистый JWT-API» CSRF-неуязвим — но JWT-схема с refresh-cookie — гибрид — CSRF-защита нужна именно refresh-эндпоинту — частая дыра); проверка Origin/Referer — дешёвый доп-слой (не основной — прокси/политики режут заголовки); защита от XSS (слои по убыванию важности): (1) контекстное экранирование вывода (HTML/атрибуты/JS/URL — разные правила — шаблонизаторы экранируют по умолчанию — выключенное экранирование (|safe, dangerouslySetInnerHTML, v-html) — главный источник дыр — «вставили HTML от пользователя» — OWASP A03 — каждый |safe — повод для ревью — чеклист); (2) CSP (Content-Security-Policy: default-src 'self' + явные источники — встроенный JS запрещён без nonce/hash — украденный вывод скриптом не исполнится — «XSS есть, а эксплойт мёртв» — вторая линия, не первая — настройка: report-only сначала (мониторинг-логика — нарушения видны до enforce), nonce на каждый ответ (статичный nonce — бесполезен), script-src 'self' без 'unsafe-inline'— последнее — частая капитуляция — [CSP-настройка](./content-security-policy-nastroyka-praktika.md) целиком); (3) валидация ввода — слабая защита от XSS (чёрные списки обходятся — экранирование вывода — настоящая граница — «санитизировали input» — иллюзия); httpOnly на сессионных cookie (XSS не украдёт cookie — не спасёт от действий от имени жертвы, но кражу сессии режет); фреймворк-реальность: React/Vue экранируют по умолчанию (дыры — dangerouslySetInnerHTML/eval-подобное), Django/Jinja2 — автоэкранирование (дыры — |safe/mark_safe), «сырой» вывод (JSON в<script>`-блоке, CSV/файлы с пользовательскими данными — XSS через скачанный файл — редкий, но живой вектор).
Где защиты ломаются: реальные дыры
Каталог провалов (из инцидентов/отчётов — не теория): SameSite=None ради кросс-доменного фронта (SPA на app.example.com, API на api.example.com — cookie кросс-сайт — SameSite=None+Secure — CSRF-защита обязательна, а её «забыли, JWT же» — refresh-эндпоинт открыт CSRF — гибрид-схема требует полного набора — JWT-FAQ); CSP с 'unsafe-inline' («иначе джаваскрипт не работает» — обычно работает — инлайн-обработчики → nonce/внешние файлы — день рефакторинга против вечной дыры); экранирование не в том контексте (значение в href="javascript:..." — HTML-экранирование не спасает — URL-контекст требует своего (javascript:-схема блокируется CSP/валидацией схемы — http(s)/mailto allowlist — «ссылка от пользователя» — классика); DOM-XSS (уязвимость целиком в клиентском JS — innerHTML = location.hash — серверные защиты бессильны — CSP + код-ревью клиентской части — ревью-практика); stored против reflected (stored — «комментарий со скриптом» — ждёт жертв на странице — массовость; reflected — «ссылка с payload в query» — нужна доставка жертве — фишинг; защита одна — экранирование вывода — классификация важна для оценки blast radius); mXSS (мутационный — браузер «перепарсивает» санитизированный HTML иначе — DOMPurify-класс библиотек против самописных санитайзеров — «написали свой sanitize regex'ом» — гарантированная дыра); XSS в загружаемых файлах (SVG со скриптом, загруженный и отданный с вашего домена с image/svg+xml — исполняется в вашем origin — фикс: отдача пользовательского контента с отдельного домена/Content-Disposition: attachment + X-Content-Type-Options: nosniff — заголовки безопасности); open redirect как XSS-помощник (?next= без whitelist — фишинг + усиление токенов — SSO-практика — relative-only или доменный allowlist); CORS-ошибка как CSRF-усилитель (Access-Control-Allow-Origin: * с credentials или рефлективный Origin — «проверяли по startsWith()» — ` — точное сравнение + allowlist — CORS-практика).
Практический сценарий: аудит и починка XSS/CSRF за спринт
Контекст: маркетплейс (Django-шаблоны + React-витрина), pen-test-отчёт: 2 XSS (stored в отзыве, reflected в поиске), CSRF на «сменить email», замечание «CSP отсутствует». Починка по слоям: (1) XSS в отзыве — источник: {{ review.text|safe }} (менеджер «чтобы переносы строк работали» — |safe отключил экранирование — «переносы» делали <br>-подстановкой из сырого текста); фикс: экранирование + linebreaksbr-фильтр Django (экранирует и ставит безопасные <br>) — или DOMPurify для богатого ввода (bold/ссылки — allowlist-теги — самописный regex-санитайзер не писали — OWASP-рекомендация); (2) reflected в поиске — value="{{ query }}" в шаблоне был безопасен, дыра — в JSON-блоке <script>window.__INITIAL__ = {{ data|safe }}</script> (кавычка в query закрывала строку — классика «JSON в HTML»); фикс: json_script-тег Django (экранирует <, >, & в JSON безопасно) — паттерн закрепили в гайдлайне: «данные в JS — только json_script, никогда |safe в script-блоке»; (3) CSRF на смену email — форма была «своя» (fetch без токена, «API же» — Django-CSRF middleware пропускал из-за @csrf_exempt «для мобильного клиента» — мобильный клиент ходил в тот же эндпоинт); фикс: разделили (сессия-эндпоинты — с CSRF, токен-API — отдельный роутер с Bearer-auth — JWT-схема — csrf_exempt по коду — grep+ревью — нашлось ещё 3 — все пересмотрены); (4) CSP — внедрение в 3 шага: Content-Security-Policy-Report-Only (отчёты — неделя сбора — 40 нарушений: инлайн-обработчики onclick в легаси-шаблонах, сторонняя аналитика без объявления) → рефакторинг (onclick → addEventListener — фронтенд-гигиена, аналитика в явный script-src) → enforce (default-src 'self'; script-src 'self' 'nonce-{random}'; img-src 'self' https:; frame-ancestors 'none' — nonce на ответ — CSP-детали); (5) тесты (регрессия навсегда): payload-набор в pytest (<script>, '"><img src=x onerror=...>, javascript: — каждый вывод-контекст; E2E с инъекцией в отзыв — Playwright — скрипт не исполнился — assertion на отсутствие alert); (6) заголовки-минимум добавили (X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, frame-ancestors вместо X-Frame-Options); итог: спринт, re-test чист; процессный вывод — grep-линты в CI (|safe, dangerouslySetInnerHTML, csrf_exempt — счётчик растёт — ревью-триггер — CI-практика — «защита от возвращения дыр» дешевле разового аудита.
FAQ
Нужен ли CSRF-токен, если cookie с SameSite=Lax? Lax закрывает основной вектор (кросс-сайт POST/формы — cookie не прикладываются) — но: кросс-сайт GET-навигация cookie при Lax отправляет (эндпоинты с side effect на GET — «/delete?id=1» — уязвимы — GET должен быть безопасным — REST-практика); SameSite=None-сценарии (кросс-доменный фронт — Lax выключен по необходимости) — полная дыра без токена; старые/специфичные клиенты (WebView-настройки) — дефолт не гарантирован; вывод: Lax — обязательный базовый слой, CSRF-токен — второй слой для state-changing-эндпоинтов на сессиях (стоимость — строка middleware — «экономия» не оправдана — защита в глубину — OWASP-подход).
Экранирование есть, откуда тогда XSS? Типичные источники: экранирование не в том контексте (HTML-escape в JS-строке/URL-параметре/атрибуте без кавычек — разные правила — контекстное экранирование); явное отключение (|safe/dangerouslySetInnerHTML/v-html/innerHTML — «одна строка ради фичи»); DOM-XSS (клиентский код читает location/document.referrer и пишет в DOM — сервер не при чём); мутации (mXSS — санитайзер пропустил, браузер перепарсил — DOMPurify против); не-HTML поверхности (SVG-загрузки, CSV-инъекция в Excel — отдельный класс, но «выполняется у пользователя»); диагностика — CSP report-only показывает реальные нарушения в проде — лучше аудита «на глаз».
Что важнее: CSP или экранирование вывода? Экранирование — первично (не допускает инъекции в DOM), CSP — вторично (инъекция случилась — исполнение блокируется — защита в глубину); практика: «CSP вместо экранирования» — провал (CSP обходится gadget-цепочками: разрешённый CDN с JSONP/угадываемый nonce — обходы существуют — CWE-позиция: CSP — mitigation, не cure); «экранирование без CSP» — рабочая база, но одна пропущенная точка вывода = полный компромисс; оба — стандарт (экранирование везде + строгий CSP с nonce — «пояс и подтяжки» — заголовки-минимум).
Как тестировать защиту от XSS/CSRF в CI?
Юнит/E2E-payload'ы: параметризованные тесты с классическими векторами (<script>alert(1)</script>, " onmouseover=, javascript: — в каждый пользовательский ввод — assertion на экранированный вывод — pytest-параметризация); E2E — инъекция через реальный UI + проверка неисполнения (Playwright — dialog-хук ловит alert); CSRF — тесты без токена/с чужим токеном → 403 (негативные кейсы — моки/тестклиент); CSP — заголовок в каждом ответе (тест-assertion) + nonce уникален на ответ (два запроса — разные nonce — «закэшировали nonce» — баг класса); статические grep-линты (|safe/dangerously*/innerHTML/eval — CI-джоба — рост числа — ревью-флаг); внешний контур — периодический DAST (OWASP ZAP в ночном пайплайне — CI-джобы — «пенсионный» минимум безопасности без платных сканеров).
Источники: OWASP CSRF/XSS Cheat Sheets, MDN CSP — ссылки в шапке.