XSS, CSRF и клиентские атаки: типы, CSP, SameSite, защита
Серверная уязвимость — это когда злоумышленник заставляет ваш код сделать не то. Клиентская — когда он заставляет сделать не то браузер вашего пользователя, причём браузер ведёт себя ровно так, как написано в спецификациях. Здесь почти нет «багов браузера», есть две особенности веб-платформы, заложенные в неё в девяностых:
- HTML — исполняемый формат. Строка из базы и строка, написанная разработчиком, попадают в один парсер, и парсер не знает, кто их автор.
- Полномочия в вебе — окружающие (ambient authority). Кука прикрепляется по признаку «куда идёт запрос», а не «кто его инициировал»: браузер аутентифицирует вас в запросе, которого вы не делали.
Первое даёт XSS, второе — CSRF. Остальное в статье — производные и соседи этих корней. Разбор идёт по одной схеме: уязвимый код → почему это работает → как починить → как убедиться, что починено.
Рамка: мы защищаем
Всё ниже написано с позиции защищающейся стороны. Примеры полезной нагрузки намеренно безобидны:
это маркеры вида alert(1), нужные, чтобы увидеть факт исполнения кода на собственном стенде, а
не готовые эксплойты. Понимать механику обязательно — нельзя починить то, чего не понимаешь.
Правило проверки: тестируйте только системы, которыми владеете сами, или те, на которые есть
письменное разрешение владельца — с зафиксированным перечнем хостов, временным окном и
контактом для эскалации; устного «мне разрешил тимлид» недостаточно. Формальная сторона — в OWASP
Web Security Testing Guide (https://owasp.org/www-project-web-security-testing-guide/) и
NIST SP 800-115 (https://csrc.nist.gov/pubs/sp/800/115/final). Полезно освежить
моделирование угроз: дальше — конкретизация букв T
(Tampering) и E (Elevation of Privilege) из STRIDE для клиентской стороны.
Две опоры клиентской безопасности
Same-Origin Policy: что она изолирует и чего нет
Origin — тройка (схема, хост, порт). Same-Origin Policy (SOP) запрещает документу одного
origin читать данные другого: DOM чужого фрейма, тело чужого ответа, чужой localStorage.
Что SOP не запрещает и что удивляет почти всех: отправлять запросы куда угодно (<img>,
<form>, fetch уходят, читать ответ нельзя); встраивать чужие ресурсы (<script src> с
чужого домена исполняется в вашем origin); навигировать чужое окно; вкладывать чужую
страницу в свой <iframe>. То есть «запрос выполнен сервером» и «ответ прочитан» — разные
события, SOP блокирует только второе. Из этого зазора и вырастает CSRF.
Origin, site и область куки — три разных периметра
Куки появились до SOP и подчиняются другим правилам: не различают порт, а без флага Secure —
и схему. Плюс отдельное понятие site (регистрируемый домен, eTLD+1 по списку
https://publicsuffix.org/), которым оперирует SameSite.
Следствия практические: поддомен маркетингового лендинга технически может писать куки на весь
ваш site, а SameSite не отличает app.example.com от blog.example.com. Спецификации:
RFC 6265 (https://www.rfc-editor.org/rfc/rfc6265) и обновление draft-ietf-httpbis-rfc6265bis,
где определены SameSite и префиксы __Host-/__Secure-.
XSS: чужой код внутри вашего origin
Cross-Site Scripting, CWE-79 (https://cwe.mitre.org/data/definitions/79.html), в OWASP Top 10 2021 входит в A03:2021 Injection. Суть: недоверенные данные попадают в вывод так, что парсер принимает их за код. Тот же механизм, что в инъекциях, только интерпретатор — браузер.
Заблуждение «ну выскочит alert» стоит убить сразу. Скрипт в вашем origin получает всё, что имеет
приложение: совершает действия от имени пользователя и читает ответы; читает DOM,
localStorage, содержимое форм вместе с только что введённым паролем; рисует поверх страницы
форму «подтвердите вход» — и пользователь прав, доверяя ей, потому что адресная строка и
сертификат настоящие. HttpOnly от XSS не спасает: он запрещает читать куку из JS, но запросы
с этой кукой скрипт всё равно делает. Это повышение стоимости атаки, а не защита.
Три типа: разница в том, где данные ночевали
до момента вывода?"} B -->|"в самом запросе"| R["Reflected XSS
сервер вернул их в HTML ответа"] B -->|"в базе, логе, имени файла"| S["Stored XSS
сервер отдаёт их каждому читателю"] B -->|"нигде — остались в браузере"| D["DOM-based XSS
сервер этих данных не видел"] R --> P1["HTML-парсер строит DOM из строки,
где шаблон и данные уже смешаны"] S --> P1 D --> P2["JS сам передаёт строку в опасный сток:
innerHTML, document.write, eval"] P1 --> X["Скрипт исполняется в origin приложения"] P2 --> X X --> C1["Действия от имени пользователя"] X --> C2["Чтение DOM, хранилищ, ответов API"] X --> C3["Подмена интерфейса и фишинг"]
Вывод из схемы: DOM-based XSS не видно в логах сервера и не поймает серверный WAF —
фрагмент URL после # вообще не отправляется на сервер. Ловится только анализом клиентского кода.
Reflected XSS: уязвимый код
// УЯЗВИМО. Express, страница результатов поиска.
app.get('/search', async (req, res) => {
const q = req.query.q ?? '';
const rows = await db.search(q); // здесь-то как раз параметризовано
res.send(`<h1>Результаты по запросу: ${q}</h1><p>Найдено: ${rows.length}</p>`);
});
Почему это работает. Ответ уходит с Content-Type: text/html, браузер запускает HTML-парсер,
тот доходит до подставленного значения и, встретив <, честно открывает новый тег. Разработчик
видел «текст внутри h1», парсер увидел разметку: граница между шаблоном и данными существовала
только в голове автора кода, в байтах ответа её нет.
Как чинить. Не строить HTML конкатенацией — шаблонизатор с автоэкранированием (Nunjucks,
Twig, Jinja, Django, Blade) закрывает базовый случай: res.render('search', { q, rows }), а в
шаблоне <h1>Результаты: {{ q }}</h1>. Если HTML собирается руками, экранирование обязано быть
контекстным — и это ровно то место, где ошибаются чаще всего.
Пять контекстов вывода — пять разных правил
Каноническая формулировка — OWASP XSS Prevention Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).
| Контекст | Что делать | Что ломает «универсальный» escapeHtml |
|---|---|---|
| Текст HTML | экранировать & < > |
ничего, это самый простой случай |
| Значение атрибута | экранировать & < > " ' + обязательные кавычки |
без кавычек хватает пробела, чтобы добавить onerror= |
URL в href/src |
allowlist схемы, затем encodeURIComponent частей |
схема javascript: не содержит спецсимволов HTML и пройдёт как есть |
| CSS-значение | allowlist значений: hex, ключевые слова, числа | CSS-парсер понимает свои \-эскейпы |
Строка в <script> |
JSON-сериализация + экранирование <, U+2028/2029 |
</script> внутри строки закрывает блок скрипта |
// Минимальный контекстный набор. В проде берите готовое — здесь важна идея.
export const escapeHtmlText = (s: string) =>
s.replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]!));
export const escapeHtmlAttr = (s: string) => // кавычки экранируем ОБЕ
s.replace(/[&<>"']/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!));
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
export function safeUrl(raw: string, base = 'https://example.com'): string {
try {
const u = new URL(raw, base); // разбираем парсером URL, а не регуляркой
return SAFE_SCHEMES.has(u.protocol) ? u.href : '#';
} catch { return '#'; } // не разобрался — значит, не ссылка
}
export const jsStringLiteral = (v: unknown) => // для вставки внутрь <script>
JSON.stringify(v).replace(/</g, '\\u003C')
.replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
Про javascript: в href стоит сказать отдельно: это самый живучий баг в React-приложениях.
<a href={userUrl}> экранирован по всем правилам JSX, но JSX экранирует спецсимволы HTML, а не
схемы URL. Проверка схемы — отдельная, ручная и обязательная.
DOM-based XSS: источники и стоки
Здесь ничего не отражается, уязвимость целиком в клиентском коде. Модель простая: source (откуда пришли данные) → sink (куда они попали).
| Источники | Стоки, исполняющие HTML/JS |
|---|---|
location.href, location.hash, location.search |
innerHTML, outerHTML, insertAdjacentHTML |
document.referrer, window.name |
document.write, document.writeln |
postMessage (event.data) |
eval, new Function, setTimeout('строка') |
localStorage, IndexedDB |
setAttribute('on*', …), href/src со схемой, srcdoc |
| ответ вашего же API (в базе — пользовательские данные) | jQuery.html(), $(строка) |
// УЯЗВИМО: классика «показать имя из фрагмента URL»
const name = decodeURIComponent(location.hash.slice(1));
document.querySelector('#greeting').innerHTML = 'Привет, ' + name;
Почему это работает. innerHTML — не «вставь текст», а «распарси строку как HTML и построй
узлы»; атрибуты вида onerror при этом становятся обработчиками. Заметьте: <script> через
innerHTML как раз не исполнится — так определено в спецификации. Поэтому самописный фильтр
«вырежем слово script» бесполезен: вектор совсем не там.
// 1) Текст — только textContent, парсер не запускается вообще
document.querySelector('#greeting').textContent = 'Привет, ' + name;
// 2) Нужна структура — стройте узлы, а не строки
const a = document.createElement('a');
a.textContent = title; // текст — как текст
a.href = safeUrl(rawUrl); // URL — через проверку схемы
container.replaceChildren(a);
Отдельная ловушка — динамические имена атрибутов: el.setAttribute(userKey, userVal). Если
userKey окажется onclick или srcdoc, экранирование значения не поможет; имена атрибутов
должны браться из фиксированного списка.
Когда HTML от пользователя действительно нужен
Комментарии с форматированием, письма, WYSIWYG — здесь HTML нужен по бизнесу. Правила: никогда
не пишите санитайзер сами (он обязан строить DOM тем же парсером, что и браузер, и переживать
mutation XSS — случаи, когда браузер при повторном разборе «чинит» разметку и меняет её смысл;
регулярки тут проигрывают заведомо); берите DOMPurify (https://github.com/cure53/DOMPurify),
bleach, jsoup; санитизируйте на выводе, а не на входе (правила меняются, а в базе уже
лежат данные, очищенные по старым); allowlist тегов, не blocklist.
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li', 'a', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'title'],
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i, // схемы — тоже allowlist
FORBID_TAGS: ['style'], // CSS-инъекции и утечки данных через CSS
RETURN_TRUSTED_TYPE: true, // дружит с Trusted Types (ниже)
});
element.innerHTML = clean;
Фреймворки: где протекает автоэкранирование
| Фреймворк | Безопасно по умолчанию | Дыра, которую делают руками |
|---|---|---|
| React | {value} в JSX экранируется |
dangerouslySetInnerHTML, href={userUrl} |
| Vue | {{ value }} экранируется |
v-html, :href без проверки схемы |
| Angular | интерполяция + встроенный санитайзер | bypassSecurityTrustHtml/Url/Script |
| Jinja2 / Django | автоэкранирование включено | фильтр ` |
| Go | html/template контекстно-зависим |
text/template для HTML, template.HTML(s) |
| Handlebars | {{ x }} экранирует |
тройные фигурные скобки не экранируют |
Правило ревью: любое из этих имён в диффе — обязательная остановка и вопрос «откуда строка». Технически ловится линтером, см. безопасную разработку.
Как убедиться, что XSS починен
// 1) Юнит-тест на экранирование — по одному на контекст
it('нейтрализует выход из значения атрибута', () => {
expect(escapeHtmlAttr(`" onerror="x`)).toBe('" onerror="x');
});
it.each(['javascript:alert(1)', 'data:text/html,x', ' javascript:x'])('отбрасывает %s', raw => {
expect(safeUrl(raw)).toBe('#');
});
// 2) Регресс-тест на конкретный найденный баг (Playwright, свой стенд)
test('имя пользователя выводится как текст, а не как разметка', async ({ page }) => {
let dialogFired = false;
page.on('dialog', async d => { dialogFired = true; await d.dismiss(); });
await createUser({ name: '<img src=x onerror=alert(1)>' }); // маркер, не эксплойт
await page.goto('/profile/42');
await expect(page.locator('#name')).toHaveText('<img src=x onerror=alert(1)>');
expect(await page.locator('#name img').count()).toBe(0); // узел не создан
expect(dialogFired).toBe(false);
});
Дальше: статический анализ — правила Semgrep/CodeQL на стоки (innerHTML,
dangerouslySetInnerHTML, |safe, bypassSecurityTrust*), дёшево и ловит регрессии в PR;
динамическая проверка — OWASP ZAP или Burp против своего стенда либо системы, на которую есть
письменное разрешение (сканер не заменяет ревью: DOM XSS он находит плохо); CSP в режиме
отчётов как детектор — неожиданный отчёт о заблокированном inline-скрипте часто оказывается
первым сигналом о живой инъекции в проде.
CSP: второй эшелон, а не замена экранированию
Content Security Policy (W3C CSP Level 3, https://www.w3.org/TR/CSP3/) — заголовок ответа, который говорит браузеру, какому коду позволено исполняться. Это страховка: она не чинит уязвимость, а ограничивает ущерб там, где экранирование пропущено.
Почему allowlist доменов не работает
Исторический подход script-src 'self' cdn.example.com разобран в исследовании Google «CSP Is
Dead, Long Live CSP!» (Weichselbaum et al., ACM CCS 2016): на выборке в миллиард с лишним хостов
около 95% политик обходились. Причины структурные — на популярных CDN лежат JSONP-эндпоинты и
фреймворки, само присутствие которых на странице даёт исполнение произвольного кода; поддерживать
такой список руками невозможно. Современный ответ — strict CSP на nonce:
Content-Security-Policy:
script-src 'nonce-R4nd0mPerRequest' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none'; base-uri 'none'; frame-ancestors 'none';
require-trusted-types-for 'script'; report-uri /csp-report
'nonce-…'— случайное значение на каждый ответ, минимум 128 бит; исполняются только скрипты с совпадающим атрибутомnonce;'strict-dynamic'— скрипт, доверенный по nonce, может добавлять другие скрипты (это нужно загрузчикам и бандлерам), а разметка из HTML — нет;https:и'unsafe-inline'— фолбэк для старых браузеров: браузеры с поддержкой'strict-dynamic'их игнорируют, старые получают хоть какую-то политику. Это не ошибка;object-src 'none'—<object>/<embed>дают исполнение мимоscript-src;base-uri 'none'— иначе инъекция<base href>уведёт все относительные скрипты;frame-ancestors 'none'— заодно закрывает clickjacking.
Чего CSP не делает: не мешает утечке данных по уже разрешённым каналам, не помогает, если инъекция попала внутрь доверенного скрипта («CSP-gadget»), не защищает от CSRF и не отменяет необходимость экранировать.
import crypto from 'node:crypto';
export function csp(req, res, next) {
// Новый nonce на КАЖДЫЙ ответ. Переиспользование nonce = отсутствие защиты.
const nonce = crypto.randomBytes(16).toString('base64'); // 128 бит
res.locals.nonce = nonce;
res.setHeader('Content-Security-Policy', [
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
`object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'`,
`require-trusted-types-for 'script'`, `report-uri /csp-report`,
].join('; '));
next();
}
// В шаблоне: <script nonce="{{ nonce }}" src="/app.js"></script>
Два запрета, которые нарушают чаще всего. Nonce нельзя кешировать: если страница отдаётся из CDN с фиксированным nonce, политика мертва — либо nonce подставляется на edge, либо страница не кешируется. Nonce нельзя ставить на скрипт, в тело которого попадают пользовательские данные — вы своими руками подписали инъекцию.
Внедрение: только через Report-Only
и приёмник отчётов ReportOnly --> Разбор : поток нарушений с прода Разбор --> Правка : наш inline-скрипт —
выносим в файл, вешаем nonce Разбор --> Шум : расширения браузера
и провайдерские инжекты — фильтруем Шум --> ReportOnly Правка --> ReportOnly ReportOnly --> Enforce : отчётов от своего кода нет N дней Enforce --> Ужесточение : убираем 'unsafe-eval',
добавляем Trusted Types Ужесточение --> Enforce : очередная итерация Enforce --> [*] : политика в проде,
отчёты собираются дальше
Content-Security-Policy-Report-Only ничего не блокирует, только шлёт отчёты. Закладывайте бюджет
на шум: расширения браузера и провайдерские инжекты дают поток нарушений, к вашему коду отношения
не имеющий — фильтруйте по blocked-uri и source-file. Готовую политику проверяйте в CSP
Evaluator (https://csp-evaluator.withgoogle.com/): он подсвечивает 'unsafe-inline' без nonce,
отсутствие object-src и base-uri и прочие типовые провалы.
Trusted Types: убрать DOM XSS структурно
Trusted Types (https://www.w3.org/TR/trusted-types/) превращают опасные стоки из «принимают строку» в «принимают специальный тип». Это единственный известный способ закрыть DOM XSS не аудитом, а типами.
// Единственная точка, где строка становится HTML. Всё остальное перестаёт работать.
window.trustedTypes?.createPolicy('default', {
createHTML: (s) => DOMPurify.sanitize(s, { RETURN_TRUSTED_TYPE: false }),
createScriptURL: (u) => {
const url = new URL(u, location.origin);
if (url.origin !== location.origin) throw new TypeError('чужой origin: ' + u);
return url.href;
},
createScript: () => { throw new TypeError('динамические скрипты запрещены'); },
});
С заголовком require-trusted-types-for 'script' любое присваивание сырой строки в innerHTML
бросает исключение. Внедрять так же: сначала Report-Only, потом enforce. Поддержка на 2026 год —
Chromium; в Firefox и Safari политика игнорируется, поэтому Trusted Types дополняют
экранирование, а не заменяют его.
CSRF: браузер аутентифицирует запрос, которого вы не делали
Cross-Site Request Forgery, CWE-352 (https://cwe.mitre.org/data/definitions/352.html). Отдельного пункта в OWASP Top 10 2021 нет — CSRF переехал в A01:2021 Broken Access Control, и это верно по смыслу: это провал контроля доступа, а не инъекция.
action=https://bank.example/transfer B->>A: POST /transfer + Cookie: session=... (браузер добавил сам) Note right of A: сервер видит валидную сессию
и не видит, кто инициировал запрос A-->>B: 302 «перевод выполнен» Note over B,E: SOP запретит evil.example ЧИТАТЬ ответ —
но действие уже произошло
Три условия, при которых CSRF возможен: полномочия окружающие (кука, Basic auth, клиентский
сертификат); все параметры запроса предсказуемы; сервер не требует ничего, чего нет у стороннего
сайта. Ломается любое — атака не работает. Bearer-токен в заголовке Authorization к CSRF не
уязвим: сторонний сайт не заставит браузер поставить этот заголовок. Но если токен вы для
удобства положили в куку и читаете оттуда — CSRF вернулся целиком (см.
JWT и токены).
# УЯЗВИМО: Flask, изменяющая операция без подтверждения происхождения
@app.post("/api/email")
def change_email():
user = current_user() # сессия из куки — есть
user.email = request.form["email"] # проверка прав — есть
db.session.commit()
return {"ok": True}
Права проверены, сессия валидна, SQL параметризован — и всё равно дыра. Сервер отвечает на вопрос «кто вы?», но не задаёт вопрос «вы ли этого хотели?». Между «пользователь аутентифицирован» и «пользователь намеревался» — пропасть, и закрывать её надо явно.
Слой 1: SameSite — необходим, но недостаточен
Set-Cookie: __Host-sid=<значение>; Secure; HttpOnly; SameSite=Lax; Path=/
SameSite=Strict — кука не уходит ни при какой кросс-сайтовой навигации: максимум защиты и
заметный UX-ущерб (переход по ссылке из мессенджера приводит на страницу разлогиненным).
SameSite=Lax — де-факто дефолт: кука уходит только при навигации верхнего уровня безопасным
методом (GET), а POST и подзапросы (<img>, fetch, iframe) остаются без неё.
SameSite=None; Secure — обязательно для встраиваемых виджетов и части OAuth-потоков; защиты
здесь нет вовсе, токен обязателен.
Почему одного SameSite мало:
- Изменяющий GET убивает защиту.
Laxпропускает GET-навигацию, значитGET /logoutилиGET /subscribe?id=7открыты настежь. Соблюдайте семантику методов (RFC 9110, https://www.rfc-editor.org/rfc/rfc9110): GET обязан быть безопасным. - Границы разные. SameSite — про site, а не про origin: уязвимый поддомен (в том числе захваченный через висящую DNS-запись) для SameSite «свой».
- Браузеры ведут себя по-разному. Chrome какое-то время применял послабление «Lax-allowing-unsafe» для свежих кук, Safari и Firefox катили дефолты по своему графику. Полагаться на поведение конкретной версии — везение, а не защита.
Слой 2: токен
Synchronizer token — эталон (OWASP CSRF Prevention Cheat Sheet, https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html): сервер держит секрет в сессии, отдаёт в HTML и требует обратно.
import hmac, secrets
from flask import session, request, abort
def issue_csrf_token() -> str:
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32) # 256 бит, CSPRNG
return session["csrf"]
def require_csrf() -> None:
sent = request.headers.get("X-CSRF-Token") or request.form.get("csrf_token", "")
expected = session.get("csrf", "")
# сравнение за постоянное время: обычное == течёт по таймингу
if not expected or not hmac.compare_digest(sent, expected):
abort(403, "CSRF token mismatch")
Signed double-submit — для stateless-бэкендов. Наивная версия («случайное значение в куке, продублированное в заголовке») сломана: поддомен или MITM по HTTP умеет ставить куки на весь site, а значит, умеет подобрать пару. Рабочая версия привязывает токен к сессии подписью:
import crypto from 'node:crypto';
const SECRET = process.env.CSRF_SECRET!; // 32+ байт из секрет-хранилища, не из репозитория
export function makeCsrfToken(sessionId: string): string {
const nonce = crypto.randomBytes(16).toString('base64url');
const mac = crypto.createHmac('sha256', SECRET).update(`${sessionId}.${nonce}`).digest('base64url');
return `${nonce}.${mac}`; // кука __Host-csrf, БЕЗ HttpOnly: её читает наш JS
}
export function verifyCsrfToken(sessionId: string, token: string): boolean {
const [nonce, mac] = String(token).split('.');
if (!nonce || !mac) return false;
const expected = crypto.createHmac('sha256', SECRET)
.update(`${sessionId}.${nonce}`).digest('base64url');
const a = Buffer.from(mac), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b); // постоянное время
}
Кука с токеном намеренно без HttpOnly — её читает ваш JS, чтобы положить в заголовок. Это
не ослабление: при XSS всё равно проиграно, а CSRF закрывается.
Слой 3: Fetch Metadata и проверка Origin
Браузеры сами сообщают, откуда пришёл запрос (Fetch Metadata, https://www.w3.org/TR/fetch-metadata/). Из JS эти заголовки не подделать, проверка дешёвая:
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
export function originGuard(req, res, next) {
if (SAFE_METHODS.has(req.method)) return next();
const site = req.get('Sec-Fetch-Site');
if (site) { // браузер поддерживает Fetch Metadata
// 'none' = пользователь ввёл адрес сам или открыл закладку — легитимно
if (site === 'same-origin' || site === 'none') return next();
return res.status(403).json({ error: 'cross-site request rejected' });
}
const ref = req.get('Referer');
const origin = req.get('Origin') ?? (ref ? new URL(ref).origin : null);
if (origin && ALLOWED_ORIGINS.has(origin)) return next();
return res.status(403).json({ error: 'origin check failed' }); // нет заголовка — отказ
}
Классическая ошибка — «если заголовка нет, значит всё в порядке». Отсутствие Origin не повод
пропускать запрос.
по RFC 9110?"} M -->|"GET/HEAD и меняет состояние"| FIX["Сначала исправить семантику:
перевести на POST/PUT/DELETE"] M -->|"нет, метод изменяющий"| AUTH{"Чем подтверждается личность?"} AUTH -->|"кука сессии"| CSRF["Нужна полная защита"] AUTH -->|"Bearer из памяти, не из куки"| OK1["CSRF неприменим,
проверить только CORS"] AUTH -->|"токен в куке"| CSRF CSRF --> L1["Слой 1: SameSite, Secure,
HttpOnly, префикс __Host-"] L1 --> L2["Слой 2: synchronizer token
или signed double-submit"] L2 --> L3["Слой 3: Sec-Fetch-Site,
фолбэк на Origin"] L3 --> SENS{"Операция критичная?"} SENS -->|"смена пароля, вывод денег"| RE["Повторная аутентификация
или второй фактор"] SENS -->|"обычная"| DONE["Готово"] RE --> DONE FIX --> AUTH
Как убедиться, что CSRF закрыт
import request from 'supertest';
const post = () => request(app).post('/api/email').set('Cookie', sessionCookie);
it('без токена — 403', () => post().send({ email: 'x@example.com' }).expect(403));
it('токен чужой сессии — 403', () =>
post().set('X-CSRF-Token', makeCsrfToken('другая-сессия')).expect(403));
it('кросс-сайтовый по Fetch Metadata — 403', () =>
post().set('X-CSRF-Token', validToken).set('Sec-Fetch-Site', 'cross-site').expect(403));
it('корректный запрос проходит', () =>
post().set('X-CSRF-Token', validToken).set('Sec-Fetch-Site', 'same-origin')
.send({ email: 'x@example.com' }).expect(200));
Плюс два приёма против системных пропусков: инвентаризация маршрутов — тест обходит роутер и
падает, если изменяющий маршрут не помечен защитным middleware (список исключений явный и с
комментарием); проверка Set-Cookie ассертом — все флаги (Secure, HttpOnly, SameSite,
префикс) утверждаются в интеграционном тесте, а не «мы вроде настроили». И не забудьте про
login CSRF: форма входа тоже требует токена, иначе пользователя можно незаметно залогинить в
подконтрольную атакующему учётку и собрать всё, что он там наделает.
Остальные клиентские атаки
Clickjacking (CWE-1021)
Ваша страница вкладывается в прозрачный iframe поверх приманки, и пользователь кликает по вашей
кнопке, думая, что кликает по чужой. Лечится заголовками: Content-Security-Policy: frame-ancestors 'none' (актуальный механизм) плюс X-Frame-Options: DENY для старых клиентов.
Значение ALLOW-FROM устарело и не работает — если встраивание кому-то разрешено, пишите
frame-ancestors https://partner.example.
Открытый редирект (CWE-601)
res.redirect(req.query.next); // УЯЗВИМО: параметр возврата после логина
Ссылка ведёт на ваш настоящий домен, пользователь ей доверяет, а приземляется на фишинговой копии. Плюс открытый редирект — стандартная деталь атак на OAuth.
// ПРАВИЛЬНО: относительный путь, никаких проверок «строка содержит наш домен»
function safeNext(next) {
if (typeof next !== 'string' || !next.startsWith('/') || next.startsWith('//')) return '/';
return next; // '//evil.example' — protocol-relative URL, отсюда вторая проверка
}
res.redirect(safeNext(req.query.next));
Проверка «начинается с нашего домена» ломается доменом вида example.com.evil.example. Если
внешние редиректы действительно нужны, сравнивайте разобранный new URL(next).host с allowlist.
CORS-мисконфигурация (CWE-942)
CORS ослабляет SOP, а не усиливает её. Смертельная комбинация — отражение Origin вместе с
разрешением на куки:
// УЯЗВИМО: любой сайт прочитает ответы, аутентифицированные кукой пользователя
res.setHeader('Access-Control-Allow-Origin', req.headers.origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
// ПРАВИЛЬНО: строгий allowlist, обязательный Vary
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const origin = req.headers.origin;
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // иначе кеш отдаст чужой заголовок
}
Access-Control-Allow-Origin: null разрешать нельзя никогда: null присылают песочные iframe и
документы из data: — ровно то, что подконтрольно атакующему. И помните: CORS не защищает от
CSRF — запрос всё равно уходит и выполняется, CORS решает лишь, покажут ли ответ.
postMessage
window.addEventListener('message', (e) => applyConfig(JSON.parse(e.data))); // УЯЗВИМО
const TRUSTED = 'https://widget.example.com'; // ПРАВИЛЬНО
window.addEventListener('message', (e) => {
if (e.origin !== TRUSTED) return; // сравнение строгое, без startsWith
if (e.source !== expectedFrame.contentWindow) return;
applyConfig(ConfigSchema.parse(e.data)); // данные всё ещё недоверенные: валидируем схемой
});
frame.contentWindow.postMessage(payload, TRUSTED); // targetOrigin явный, '*' — только публичное
Tabnabbing и window.opener
Ссылка с target="_blank" без rel="noopener" даёт открытой странице window.opener и право
навигировать вашу вкладку: пользователь возвращается на «ту же» вкладку, а там форма входа —
подделка. Современные браузеры подразумевают noopener для target="_blank", но в
пользовательском HTML, в старых движках и в window.open() это не гарантировано:
<a href="https://external.example" target="_blank" rel="noopener noreferrer">внешняя ссылка</a>
Prototype pollution (CWE-1321)
Специфика JavaScript: рекурсивное слияние пользовательского JSON может записать свойство в
Object.prototype, после чего оно «появится» у всех объектов приложения. Само по себе это не
XSS, но становится им, когда шаблонизатор читает загрязнённое свойство и подставляет его в HTML.
// УЯЗВИМО: for..in идёт по цепочке прототипов и не фильтрует служебные имена
function merge(target, src) {
for (const k in src) {
if (typeof src[k] === 'object' && src[k]) merge(target[k] ??= {}, src[k]);
else target[k] = src[k];
}
return target;
}
// ПРАВИЛЬНО: только собственные ключи и никаких служебных имён
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, src) {
for (const k of Object.keys(src)) {
if (BLOCKED.has(k)) continue;
const v = src[k];
target[k] = (v && typeof v === 'object' && !Array.isArray(v))
? safeMerge(Object.create(null), v) : v;
}
return target;
}
Надёжнее — валидировать вход схемой (Zod, Ajv) и не сливать произвольные объекты вообще; как
дополнительный пояс Object.freeze(Object.prototype) на старте приложения (ломает часть
библиотек, но проверяется одним прогоном тестов).
Утечки: Referer, изоляция, SRI
Referrer-Policy: strict-origin-when-cross-origin— иначе токен сброса пароля из URL уедет в чужую аналитику вместе сReferer. Секреты в URL — отдельная плохая идея: они оседают в логах прокси и истории браузера.- COOP/COEP/CORP.
Cross-Origin-Opener-Policy: same-originразрывает связь окон и убивает целый класс XS-Leaks;Cross-Origin-Resource-Policy: same-originзапрещает чужим страницам подгружать ваши ресурсы; вместе сCross-Origin-Embedder-Policy: require-corpдают crossOriginIsolated. Обзор атак: https://xsleaks.dev/. - Subresource Integrity (https://www.w3.org/TR/SRI/) для скриптов с CDN:
<script src="…" integrity="sha384-…" crossorigin="anonymous">. Подменённый файл браузер не исполнит — точка соприкосновения с цепочкой поставок.
Минимальный набор заголовков в прод
| Заголовок | Значение | Что закрывает |
|---|---|---|
Content-Security-Policy |
script-src 'nonce-…' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none' |
ущерб от XSS |
Content-Security-Policy |
frame-ancestors 'none' |
clickjacking |
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload |
downgrade, кражу кук по HTTP |
X-Content-Type-Options |
nosniff |
MIME-sniffing, «картинку» с HTML внутри |
Referrer-Policy |
strict-origin-when-cross-origin |
утечку путей и токенов |
Cross-Origin-Opener-Policy |
same-origin |
XS-Leaks через ссылки на окна |
Cross-Origin-Resource-Policy |
same-origin |
горячие ссылки и часть XS-Leaks |
Permissions-Policy |
camera=(), microphone=(), geolocation=() |
доступ к API из встроенных фреймов |
# Общие заголовки — на уровне прокси; CSP с nonce — только из приложения.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Resource-Policy "same-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
Флаг always обязателен: без него nginx не добавит заголовок к ответам 4xx/5xx, а страница
ошибки — тоже HTML, в который можно что-нибудь отразить. Порядок внедрения по цене: флаги кук,
nosniff, Referrer-Policy, HSTS — на этой неделе; CSRF-токены и контекстное экранирование —
следующим шагом; строгий CSP и Trusted Types — задача на квартал, потому что они требуют разбора
всего inline-кода.
Типичные ошибки
- Одна
escapeHtml()на все контексты. Атрибут без кавычек,javascript:вhref,</script>внутри JS-строки — три классических пробоя. - Санитизация на входе вместо вывода и blocklist вместо allowlist: правила меняются,
а «вырежем
<script>» не работает — HTML богаче, чем кажется. - Вера, что
HttpOnlyили CSP спасают от XSS. Оба лишь повышают стоимость атаки и ограничивают ущерб; уязвимость чинится в коде. - Nonce, одинаковый на все ответы или закешированный CDN, и
'unsafe-inline'без nonce — политика становится декорацией. - Изменяющие GET-эндпоинты.
SameSite=Laxпропускает их по определению. - Наивный double-submit без подписи и привязки к сессии.
- Вера, что CORS защищает от CSRF. Не защищает: запрос уходит и выполняется.
- Отражение
OriginвAccess-Control-Allow-Originвместе сAllow-Credentials: true. - Проверка
Referer, пропускающая запросы без заголовка. Отсутствие — не «всё ок». postMessageбез проверкиe.originи отправка сtargetOrigin: '*'.- Данные из своего же API считаются доверенными. Их в базу положил пользователь.
- Секреты и токены в URL. Уезжают в
Referer, логи прокси и историю браузера.
Мини-итог
- XSS и CSRF растут из двух свойств платформы: HTML исполняется, а куки прикрепляются автоматически. Понимание этих двух фактов заменяет заучивание списка приёмов.
- XSS чинится контекстным выводом: текст —
textContent, атрибут — с кавычками и экранированием, URL — с allowlist схем, JS — через JSON, богатый HTML — через DOMPurify. - CSP — второй эшелон, и работает только строгий вариант: nonce на каждый ответ,
'strict-dynamic',object-src 'none',base-uri 'none'; внедрение — через Report-Only. Trusted Types — единственный структурный способ закрыть DOM XSS: они меняют тип данных, а не полагаются на бдительность ревьюера. - CSRF закрывается тремя слоями:
SameSite+Secure+HttpOnly+ префикс__Host-; токен (synchronizer или signed double-submit); проверкаSec-Fetch-Site/Origin. Для критичных операций — повторная аутентификация. - Каждое исправление сопровождается тестом: не «мы починили», а «есть красный тест, который позеленел и больше не даст этому вернуться». Любая проверка «а сработает ли» — только на своём стенде или по письменному разрешению владельца системы, с зафиксированным перечнем хостов.
Источники
- OWASP Cheat Sheet Series (XSS Prevention, DOM based XSS Prevention, CSRF Prevention, CSP, Clickjacking Defense): https://cheatsheetseries.owasp.org/; OWASP Top 10 2021: https://owasp.org/Top10/; WSTG: https://owasp.org/www-project-web-security-testing-guide/
- CWE-79, CWE-352, CWE-601, CWE-942, CWE-1021, CWE-1321: https://cwe.mitre.org/
- W3C: CSP Level 3 (https://www.w3.org/TR/CSP3/), Trusted Types (https://www.w3.org/TR/trusted-types/), Fetch Metadata (https://www.w3.org/TR/fetch-metadata/), SRI (https://www.w3.org/TR/SRI/)
- RFC 6265 и draft-ietf-httpbis-rfc6265bis: https://www.rfc-editor.org/rfc/rfc6265; RFC 9110 (свойства HTTP-методов): https://www.rfc-editor.org/rfc/rfc9110
- Weichselbaum, Spagnuolo, Lekies, Kotowicz. «CSP Is Dead, Long Live CSP!», ACM CCS 2016; NIST SP 800-115: https://csrc.nist.gov/pubs/sp/800/115/final
- Michal Zalewski. «The Tangled Web: A Guide to Securing Modern Web Applications», No Starch Press; XS-Leaks Wiki: https://xsleaks.dev/
Что дальше
Мы закрыли периметр, где чужой код исполняется в браузере пользователя и где браузер подписывает запросы за него. Следующий вопрос — что стоит за той самой кукой сессии: как проверяются пароли, как их хранить, чтобы утечка базы не стала катастрофой, чем сессия отличается от токена и почему восстановление доступа — самая недооценённая дыра в аутентификации.
Аутентификация: пароли, хеширование, сессии, MFA, восстановление доступа