{ "index": 63, "slug": "editorial-2026-04-practice-modern-web-security", "title": "Безопасность веба начинается с границы: как доказать, что контроль прерывает атаку", "excerpt": "CSP, CSRF-токен, CORS и проверка прав останавливают разные шаги атаки. Разбираем карту пути, рабочий пример с nonce, отрицательный тест и границы того, что действительно доказано.", "contentHtml": "

Симптом знакомый: в ответе есть CSP, cookie помечена HttpOnly, API настроил CORS, а endpoint изменения профиля проверяет сессию. В отчёте появляется фраза «защита включена». Но команда не может ответить на более узкий вопрос: какой контроль должен прервать конкретный путь атаки и какое наблюдение это подтверждает? Без такого ответа тест заголовка легко принять за доказательство защиты данных.

\n

Цена ошибки — не только уязвимость. Во время инцидента приходится заново выяснять, проходили ли входные данные через небезопасный sink (место вывода или выполнения), отправлялась ли cookie, менялось ли состояние и на каком слое ожидали отказ. Практичнее разделить четыре объекта: asset — что защищаем, attack path — как к нему добираются, control — какой переход запрещаем, и evidence — что можно повторить и увидеть.

\n

Начните с наблюдаемого пути, а не со списка заголовков

\n

Возьмём учебный маршрут профиля. Пользователь вводит имя, сервер сохраняет его и выводит на странице. Если значение попадает в HTML без экранирования, атакующий может передать фрагмент, который браузер воспримет как разметку или скрипт. Последовательность выглядит так: fragment → render sink → document → script execution. Здесь asset — страница профиля, а опасный побочный эффект — выполнение скрипта в контексте приложения.

\n

У каждого шага свой владелец. Валидатор и экранирование отвечают за смысл и безопасный вывод значения. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе. Браузер применяет политику, но не исправляет серверный шаблон. Поэтому ответ «в заголовке есть Content-Security-Policy» подтверждает доставку политики, а не безопасность каждого sink.

\n
Карта проверки веб-атаки: недоверенный фрагмент проходит к render sink и документу, CSP nonce прерывает выполнение, а источник фрагмента и непокрытые sink остаются остаточным риском
Карта связывает один маршрут с одной точкой прерывания и одним наблюдением. Остаточный риск не исчезает из-за положительного теста: источник фрагмента и другие sink нужно проверять отдельно.
\n

Для CSRF путь другой: внешняя страница создаёт запрос, браузер может приложить cookie, сервер принимает операцию записи. CORS управляет тем, сможет ли чужой скрипт прочитать ответ; он не является универсальным запретом на отправку запроса. Нельзя перенести evidence из одного маршрута на другой только потому, что оба проходят через браузер.

\n

Разведите контроль и доказательство

\n

Control полезно описывать глаголом: «экранирует значение перед вставкой», «отклоняет скрипт без разрешённого nonce», «отказывает в mutation без CSRF-токена», «не разрешает субъекту изменить чужой объект». Такая формулировка сразу показывает место проверки. Формула «CSP настроена» скрывает и переход, и ожидаемый результат.

\n

Evidence должно содержать вход, маршрут, наблюдение и границу вывода. Например: «на тестовом стенде страница с маркером xss_probe вернула CSP-заголовок; браузер заблокировал inline script без nonce; маркер не появился в журнале выполнения». Это подтверждает одну политику и один сценарий. Оно не доказывает безопасный вывод в другом шаблоне, отсутствие XSS во всём приложении или правильность конфигурации после прокси.

\n
Какой вопрос отвечает каждый контроль
КонтрольКакой переход ограничиваетМинимальное наблюдениеЧто не доказано
Экранирование и безопасный sinkНедоверенное значение превращается в HTML или кодВ DOM появляется текст, а не узел, который выполняет кодДругие шаблоны, sink и данные из другого источника
CSP с nonceСкрипт без разрешённого nonce выполняется в документеБраузер блокирует отрицательный сценарий и фиксирует нарушение политикиЧто значение безопасно сформировали до вывода; поведение неподдерживаемого клиента
CSRF-токенЧужой сайт отправляет state-changing запрос с cookie без доказательства намеренияЗапрос без токена получает отказ, запись не меняетсяXSS, права на чужой объект и маршруты без этого middleware
CORSЧужой скрипт читает ответ cross-origin запросаБраузер не отдаёт response body скрипту при запрещённом originЗапрос без preflight не дошёл до сервера и состояние не изменилось
АвторизацияСубъект изменяет объект вне своей области правЗапрос с валидной сессией к чужому объекту получает 403, запись неизменнаНаличие CSRF-защиты и безопасность клиентского кода
HttpOnly / SameSiteЧтение cookie скриптом или отправка cookie в части cross-site-контекстовФактические атрибуты cookie и запрос в проверяемом контекстеПолная защита от CSRF, XSS и украденной сессии
\n

Таблица не заменяет тест-план. Она не говорит, что любой control обязателен для любого endpoint. Её задача — не дать одному зелёному наблюдению ответить сразу на пять разных вопросов.

\n

Учебный пример: nonce не отменяет экранирование

\n

Ниже — фрагмент в стиле Express для страницы, которая выводит имя. Он показывает порядок границ, но не является готовым middleware: escapeHtml, генерация nonce, шаблонизатор, заголовки прокси и обработка ошибок должны иметь реальные реализации и тесты. Важна последовательность: данные обезвреживаются до вывода, а политика передаётся браузеру вместе с документом.

\n
import { randomBytes } from 'node:crypto';\n\napp.get('/profile', (req, res) => {\n  const nonce = randomBytes(16).toString('base64');\n  const safeName = escapeHtml(String(req.query.name ?? ''));\n\n  res.set('Content-Security-Policy', [\n    "default-src 'self'",\n    "script-src 'nonce-" + nonce + "'",\n    "object-src 'none'",\n    "base-uri 'none'",\n  ].join('; '));\n\n  const html = '<h1>' + safeName + '</h1>' +\n    '<script nonce="' + nonce + '">' +\n    'window.profilePageReady = true;' +\n    '</script>';\n  res.send(html);\n});
\n

В этом коде nonce разрешает только явно помеченный скрипт текущего документа. Он не делает safeName безопасным: значение всё равно нужно корректно экранировать или передавать через безопасный API шаблонизатора. Если убрать экранирование, оставить только CSP и считать задачу решённой, доказательство будет неполным. W3C прямо описывает CSP как defence-in-depth для снижения последствий инъекции, а не замену проверке входа и безопасному выводу.

\n

Есть и практическое ограничение: пример не показывает, как фреймворк выставляет заголовок при ошибке, как политика проходит через CDN и как приложение обрабатывает inline-обработчики, сторонние скрипты и динамическую загрузку. Эти решения могут потребовать другие директивы и отдельные тесты. Нельзя добавлять 'unsafe-inline' только ради того, чтобы старый тест перестал падать: это меняет саму границу, которую вы хотели проверить.

\n

Воспроизводимая проверка на разрешённом стенде

\n

Проверка должна отделять HTTP-наблюдение от поведения браузера. Команды ниже ничего не меняют: они скачивают страницу и заголовки с тестового URL. Подставьте адрес своего стенда, где разрешена проверка, а не production-сайта. Имя файла в примере — локальный временный артефакт ревью.

\n
BASE_URL='https://staging.example.test'\nPAGE_PATH='/profile?name=xss_probe'\n\ncurl --fail-with-body -sS \\\n  -D /tmp/profile.headers \\\n  "$BASE_URL$PAGE_PATH" \\\n  -o /tmp/profile.html\n\nrg -n -i '^content-security-policy:' /tmp/profile.headers\nrg -n 'xss_probe|nonce=' /tmp/profile.html\n\n# Ожидаем заголовок CSP и текстовый маркер.\n# Этот curl-запуск не доказывает, что браузер заблокировал код.\n! rg -n -i '<script[^>]*>[^<]*(xss_probe|alert)\\b' /tmp/profile.html
\n

Для отрицательного сценария нужен браузерный тест. В него передают безопасный маркер, слушают событие нарушения CSP и проверяют, что код с неправильным nonce не создал наблюдаемого эффекта. Если тест запускается на странице с nonce, значение nonce нельзя зашивать в ожидание: сначала получите реальный документ, затем отдельно сформируйте намеренно неверный вариант. Иначе тест подтвердит только наличие атрибута, а не работу политики.

\n
const violations = [];\npage.on('console', message => {\n  if (message.type() === 'error') violations.push(message.text());\n});\n\nawait page.goto(baseUrl + '/profile?name=xss_probe');\nawait page.evaluate(() => {\n  const script = document.createElement('script');\n  script.textContent = 'window.__xssProbe = true';\n  script.setAttribute('nonce', 'wrong-nonce');\n  document.body.append(script);\n});\n\nconst executed = await page.evaluate(() => window.__xssProbe === true);\nif (executed) throw new Error('unexpected script execution');\nconsole.log({ executed, consoleErrors: violations.length });
\n

Результат нужно записать точнее, чем «XSS закрыт»: «в браузере версии, использованной в тесте, скрипт с неверным nonce не выполнился; HTTP-ответ содержал ожидаемый CSP; безопасный вывод маркера проверен только на маршруте /profile». Если браузерный тест не поймал ошибку консоли, это ещё не повод ослаблять политику: консольные сообщения зависят от браузера. Надёжнее проверять отсутствие эффекта и, при необходимости, событие securitypolicyviolation.

\n

Почему CORS, CSRF и права нельзя объединять в один verdict

\n

Для cross-origin fetch браузер применяет CORS к чтению ответа. Некоторые запросы с безопасными для CORS методом, заголовками и типом содержимого не требуют preflight. Исторически HTML-форма и без этого механизма могла отправить запрос на другой origin, поэтому отсутствие ответа у чужого скрипта не означает отсутствие побочного эффекта на сервере. Если операция использует cookie и меняет состояние, серверу нужна отдельная CSRF-защита или эквивалентная проверка.

\n

CSRF-токен отвечает на другой вопрос: есть ли у запроса доказательство, которое внешний сайт не может получить из обычного cross-site сценария. Он не решает, имеет ли субъект право изменить объект. Для этого сервер сравнивает субъект сессии и владельца ресурса. HttpOnly мешает JavaScript прочитать cookie, но браузер всё равно может отправить её по правилам cookie. SameSite ограничивает часть отправок в cross-site-контексте, однако результат зависит от атрибута, браузера, схемы и типа навигации.

\n

Так же CSP не заменяет ни CSRF, ни авторизацию. Скрипт, который уже выполняется в доверенном контексте приложения, может вызвать разрешённую операцию; политика ресурсов не определяет права пользователя. Верный review не спрашивает «включена ли безопасность», а сопоставляет asset, угрозу, контроль, негативный тест и непроверенные соседние маршруты.

\n

Порядок проверки

\n
  1. Выберите один asset и один побочный эффект: например, имя в странице профиля или изменение email.
  2. Опишите attack path глаголами: откуда приходит значение, через какой sink проходит, где появляется документ и какое действие возможно.
  3. Назначьте один control на один переход. Для CSP это выполнение запрещённого скрипта, для CSRF — запись без токена, для авторизации — доступ к чужому объекту.
  4. Запишите позитивный и негативный сценарии. Позитивный показывает разрешённый контракт, негативный должен доходить до точки отказа и не менять состояние.
  5. Проверьте HTTP отдельно: статус, заголовок, cookie-атрибуты и тело ответа. Не выдавайте отсутствие response body за отсутствие серверного эффекта.
  6. Проверьте браузер отдельно: реальное применение CSP, выполнение скрипта, preflight и отправка credentials зависят от контекста и реализации клиента.
  7. Сверьте покрытие: найдите соседние шаблоны, mutation, формы, фоновые обработчики и прокси, которые не участвовали в тесте.
  8. Передайте результат четырьмя полями: asset, interruption, evidence и not-proven. Если одного поля нет, оставьте следующий шаг, а не общий verdict.
\n

Ограничения применимости

\n

Это метод локальной проверки одного маршрута, а не сертификат безопасности приложения. Он не покрывает автоматически WebSocket, GraphQL, загрузку файлов, OAuth callback, Service Worker, фоновые очереди и native-клиенты. Для каждого канала нужно построить собственный путь и назвать границу, которая действительно применяется.

\n

Nonce и CSP зависят от того, как формируется документ и какие скрипты нужны странице. Политика из примера может сломать легитимную аналитику или inline-код. Не переносите её в другой сервис без инвентаризации ресурсов. curl не исполняет HTML и потому не подтверждает браузерную защиту. Браузерный тест не доказывает, что сервер безопасно выводит все данные. Тест на тестовом стенде не доказывает конфигурацию после deploy.

\n

CSRF-токен не защищает от украденной сессии или скрипта, который уже выполняется в доверенном origin. CORS не является заменой контролю записи. HttpOnly и SameSite уменьшают отдельные риски, но не являются универсальной защитой. Если команда не может назвать непроверенный sink, маршрут или контекст cookie, карта слишком широкая для честного вывода.

\n

Критерий готовности

\n

Для выбранной операции готово не «всё защищено», а более узкое утверждение: путь атаки записан; control стоит до опасного эффекта; позитивный сценарий работает; негативный сценарий получает ожидаемый отказ и не меняет состояние; HTTP- и браузерное наблюдения разделены; список непроверенных маршрутов сохранён. После изменения шаблона, middleware, cookie-политики, прокси или браузера этот набор нужно повторить.

\n

Такой критерий даёт команде полезный результат: видно, что остановлено, где осталось residual risk и кто должен выполнить следующий review. Если evidence подтверждает только заголовок, пишите только про заголовок. Если тест подтверждает один sink, не называйте его защитой всего приложения.

\n

Проверяемые источники

" }