Безопасность приложений XSS, CSRF и клиентские атаки: типы, CSP, SameSite, защита
0%

XSS, CSRF и клиентские атаки: типы, CSP, SameSite, защита

XSS, CSRF и клиентские атаки: типы, CSP, SameSite, защита

Серверная уязвимость — это когда злоумышленник заставляет ваш код сделать не то. Клиентская — когда он заставляет сделать не то браузер вашего пользователя, причём браузер ведёт себя ровно так, как написано в спецификациях. Здесь почти нет «багов браузера», есть две особенности веб-платформы, заложенные в неё в девяностых:

  1. HTML — исполняемый формат. Строка из базы и строка, написанная разработчиком, попадают в один парсер, и парсер не знает, кто их автор.
  2. Полномочия в вебе — окружающие (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.

Origin, site и область действия куки — три несовпадающие границы

Следствия практические: поддомен маркетингового лендинга технически может писать куки на весь ваш 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, но запросы с этой кукой скрипт всё равно делает. Это повышение стоимости атаки, а не защита.

Три типа: разница в том, где данные ночевали

Вывод из схемы: 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]!));

export const escapeHtmlAttr = (s: string) =>   // кавычки экранируем ОБЕ
  s.replace(/[&<>"']/g, c =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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('&quot; onerror=&quot;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

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, и это верно по смыслу: это провал контроля доступа, а не инъекция.

Три условия, при которых 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 мало:

  1. Изменяющий GET убивает защиту. Lax пропускает GET-навигацию, значит GET /logout или GET /subscribe?id=7 открыты настежь. Соблюдайте семантику методов (RFC 9110, https://www.rfc-editor.org/rfc/rfc9110): GET обязан быть безопасным.
  2. Границы разные. SameSite — про site, а не про origin: уязвимый поддомен (в том числе захваченный через висящую DNS-запись) для SameSite «свой».
  3. Браузеры ведут себя по-разному. 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 не повод пропускать запрос.

Как убедиться, что 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-кода.

Типичные ошибки

  1. Одна escapeHtml() на все контексты. Атрибут без кавычек, javascript: в href, </script> внутри JS-строки — три классических пробоя.
  2. Санитизация на входе вместо вывода и blocklist вместо allowlist: правила меняются, а «вырежем <script>» не работает — HTML богаче, чем кажется.
  3. Вера, что HttpOnly или CSP спасают от XSS. Оба лишь повышают стоимость атаки и ограничивают ущерб; уязвимость чинится в коде.
  4. Nonce, одинаковый на все ответы или закешированный CDN, и 'unsafe-inline' без nonce — политика становится декорацией.
  5. Изменяющие GET-эндпоинты. SameSite=Lax пропускает их по определению.
  6. Наивный double-submit без подписи и привязки к сессии.
  7. Вера, что CORS защищает от CSRF. Не защищает: запрос уходит и выполняется.
  8. Отражение Origin в Access-Control-Allow-Origin вместе с Allow-Credentials: true.
  9. Проверка Referer, пропускающая запросы без заголовка. Отсутствие — не «всё ок».
  10. postMessage без проверки e.origin и отправка с targetOrigin: '*'.
  11. Данные из своего же API считаются доверенными. Их в базу положил пользователь.
  12. Секреты и токены в 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. Для критичных операций — повторная аутентификация.
  • Каждое исправление сопровождается тестом: не «мы починили», а «есть красный тест, который позеленел и больше не даст этому вернуться». Любая проверка «а сработает ли» — только на своём стенде или по письменному разрешению владельца системы, с зафиксированным перечнем хостов.

Источники

Что дальше

Мы закрыли периметр, где чужой код исполняется в браузере пользователя и где браузер подписывает запросы за него. Следующий вопрос — что стоит за той самой кукой сессии: как проверяются пароли, как их хранить, чтобы утечка базы не стала катастрофой, чем сессия отличается от токена и почему восстановление доступа — самая недооценённая дыра в аутентификации.

Аутентификация: пароли, хеширование, сессии, MFA, восстановление доступа

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов