{ "index": 62, "slug": "editorial-2026-04-mechanism-modern-web-security", "title": "Почему список security controls не доказывает защиту веб-приложения", "excerpt": "CSP, HttpOnly, CORS и сканер отвечают на разные вопросы. Разбираем путь от угрозы к точке прерывания, связываем его с проверяемым evidence и оставляем остаточный риск там, где данных недостаточно.", "contentHtml": "

Проблема security review часто обнаруживается не в отсутствии мер, а в слишком сильном выводе. В отчёте стоят галочки «CSP включён», «cookie с HttpOnly», «сканер чист», но никто не может показать, какой запрос или фрагмент данных остановил каждый control. Если после этого endpoint меняет профиль по чужому POST, список мер создаёт ложное ощущение закрытой уязвимости.

\n

Удобнее разложить проверку на четыре связанных вопроса: какой asset защищаем, как выглядит ordered attack path, где control прерывает путь и что именно наблюдалось. Последняя часть — evidence, то есть воспроизводимое подтверждение с ограниченным выводом. Всё, что не проверено, остаётся residual risk. Такая схема не обещает безопасность всего приложения, зато не позволяет одной настройке присвоить себе результат чужого теста.

\n

Список мер не хранит причинность

\n

Строка «CORS настроен» описывает настройку чтения ответа из браузера, но не отвечает, принял ли сервер изменяющий запрос. HttpOnly не даёт JavaScript прочитать cookie, но браузер по-прежнему может приложить её к запросу. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе, но не исправляет небезопасный HTML-sink. Даже хороший SAST-анализ говорит только о просмотренных файлах, правилах и версии запуска.

\n

Один и тот же control может быть полезен в одном месте и бесполезен в другом. Поэтому вместо существительного нужен глагол: «сервер отклоняет POST без действительного CSRF-токена до записи в профиль». В этой формулировке видны операция, условие отказа и момент, до которого должен сохраниться asset. Её можно сопоставить с тестом. «CSRF включён» сопоставить не с чем.

\n
\"Схематичное
Безопасность веба пересекает несколько границ. Иллюстрация перечисляет соседние механизмы, но сама по себе не доказывает их настройку; доказательством становится только привязанный к конкретной операции тест.
\n

Один путь показывает разницу между CORS и CSRF

\n

Возьмём изменение email. Пользователь вошёл в bank.example, браузер хранит cookie сессии, а endpoint принимает POST /api/profile/email. Внешняя страница может попытаться отправить такой запрос с cookie. CORS определяет, может ли внешний origin прочитать ответ через браузерный API. CSRF-защита должна не допустить нежелательное изменение состояния на сервере.

\n
<form action="https://bank.example/api/profile/email" method="POST">\n  <input name="email" value="attacker@example.net">\n</form>\n<script>document.forms[0].submit()</script>\n\n// Сценарий приведён для анализа потока.\n// Не запускайте его против чужого сервиса.
\n

Если endpoint доверяет только cookie, чужой запрос может дойти до операции записи независимо от того, увидит ли внешний сайт тело ответа. Проверка заголовка Origin, synchronizer token или другой серверный механизм может прервать путь. Свойство SameSite уменьшает часть cross-site-сценариев, но зависит от контекста запроса и политики cookie. Ни один из этих controls нельзя объявлять заменой аутентификации или авторизации.

\n

Здесь полезно записать путь по шагам: external-page → browser-attaches-cookie → POST-email → profile-changed. CORS относится к чтению ответа, CSRF-проверка — к допуску изменяющего запроса, авторизация — к праву менять конкретный профиль. Если evidence проверяет только заголовок ответа, из него нельзя выводить, что запись не произошла.

\n

Как связать threat, control и evidence

\n

Для каждой операции составьте маленькую карточку. asset — изменяемый объект. precondition — условие, при котором путь возможен. attackPath — упорядоченные действия. interruption — точный запрет. evidence — наблюдение, которое можно повторить. Поле notProven защищает от расширения вывода при пересказе.

\n
{\n  "asset": "profile.email",\n  "precondition": "valid_session_cookie",\n  "attackPath": [\n    "external_page",\n    "browser_attaches_cookie",\n    "POST_profile_email",\n    "profile_changed"\n  ],\n  "interruption": "reject_without_csrf_token_before_write",\n  "evidence": "403_and_email_unchanged_in_test_environment",\n  "notProven": [\n    "all_other_write_endpoints",\n    "authorization_for_other_profiles",\n    "production_proxy_behavior"\n  ]\n}
\n

Карточка не является security verdict. Она фиксирует область одного утверждения. Если проверка вернула 403, но после запроса email изменился, статус ответа не спасает вывод: контроль сработал слишком поздно или тест смотрит не на тот asset. Если идентификатор endpoint отсутствует, карточку нельзя переносить на весь API.

\n

Воспроизводимый тест серверной границы

\n

Проверять нужно не только ответ, но и состояние после отказа. Ниже — команда для локального или специально разрешённого тестового сервиса. Подставьте адрес своего стенда и тестовую cookie; домен из примера не является целью для запроса.

\n
BASE=http://127.0.0.1:3000\n\ncurl --fail-with-body -i -X POST "$BASE/api/profile/email" \\\n  -H 'Origin: https://attacker.example' \\\n  -H 'Content-Type: application/x-www-form-urlencoded' \\\n  -b 'session=TEST_SESSION' \\\n  --data 'email=attacker@example.net'\n\n# Ожидание для защищённого тестового endpoint:\n# HTTP/1.1 403 Forbidden\n# и прежнее значение profile.email после запроса.
\n

Команда воспроизводима только при известных маршруте, тестовой сессии и контракте ответа. TEST_SESSION не должен быть настоящим секретом. Если сервис возвращает 401, сначала не создана аутентифицированная тестовая сессия; это не доказательство CSRF-защиты. Для положительной ветки повторите запрос с действительным CSRF-токеном, сохраните новый ответ и отдельно подтвердите ожидаемое изменение email.

\n

Минимальный псевдокод показывает место отказа. В реальном приложении названия функций, хранилище токенов и формат ошибок будут другими.

\n
app.post('/api/profile/email', async (req, res) => {\n  const session = await getSession(req);\n  if (!session) return res.sendStatus(401);\n\n  if (!sameOrigin(req.get('Origin')) ||\n      !validCsrfToken(req.get('X-CSRF-Token'), session)) {\n    return res.sendStatus(403);\n  }\n\n  const email = parseEmail(req.body.email);\n  if (!email) return res.sendStatus(400);\n\n  if (!(await canEditProfile(session.userId, req.body.profileId))) {\n    return res.sendStatus(403);\n  }\n\n  await updateEmail(req.body.profileId, email);\n  return res.sendStatus(204);\n});
\n

Порядок здесь важен: сессия, намерение запроса, формат значения, право на объект, затем запись. Это не универсальный middleware и не готовая библиотека. Он не показывает, как сравнивать секреты, ротировать токены, проводить логирование или защищать другие каналы. Эти детали нужно проверять по фреймворку и коду конкретного сервиса.

\n

Что именно считать evidence

\n

Evidence — это не обязательно скриншот. Им может быть HTTP-ответ вместе с проверкой состояния, тестовый лог с correlation id, результат статического анализа с зафиксированным scope или браузерное наблюдение для конкретной политики. У каждого наблюдения должен быть допустимый вывод. Например, ответ 403 на POST без токена подтверждает отказ этой ветки на данном endpoint в данном окружении. Он не подтверждает защиту всех mutations.

\n

Записывайте границу рядом с результатом. «В тесте запрос без токена получил 403, запись не изменилась; не проверены фоновые задачи, соседние маршруты и production proxy» — полезное утверждение. «CSRF закрыт» — уже нет. Точно так же наличие Content-Security-Policy показывает доставку заголовка. Оно не доказывает, что каждый пользовательский фрагмент безопасно закодирован и ни один sink не исполняет данные.

\n
От наблюдаемого симптома к следующему действию
СимптомЧто он означаетПроверкаСледующее действие
Чужой origin получает CORS errorБраузер ограничил чтение ответаПроверить, изменилось ли состояние после отдельного POSTОставить серверную CSRF-проверку, если операция использует cookie
Cookie имеет HttpOnlyСкрипт не читает её значение через API браузераПроверить фактический запрос и серверную проверку намеренияНе выдавать HttpOnly за защиту от CSRF
POST без токена вернул 403Одна отрицательная ветка остановленаСверить asset, endpoint, окружение и состояние после запросаЗаписать scope; отдельно проверить другие write-маршруты
Токен есть, но меняется чужой профильCSRF не заменяет авторизациюПовторить с объектом другого пользователяПроверить владельца ресурса до записи
CSP есть, но пользовательский HTML исполняетсяЗащита документа не исправила источник или sinkПроверить вывод, кодирование и response policy для этой страницыИсправить безопасный рендеринг; CSP оставить дополнительным слоем
\n

Почему CSP не исправляет XSS в одиночку

\n

W3C описывает Content Security Policy как механизм управления ресурсами и выполнением, который снижает риск content injection и работает как defense-in-depth. Там же явно сказано, что CSP не заменяет внимательную валидацию входа и кодирование вывода. Поэтому в трассе CSP может прерывать попытку выполнить неразрешённый скрипт, но не доказывает, что строка безопасно попала в HTML, атрибут или DOM-sink.

\n

Для одного пользовательского поля нужны разные проверки: допустимый формат на сервере, безопасный контекст вывода и отрицательный тест для конкретной точки рендера. Политика с script-src и nonce относится к браузерному исполнению. Она не даёт права убрать тесты для шаблона или клиентского кода. Если policy меняется, повторно проверьте доставленный заголовок и позитивные сценарии приложения: чрезмерно строгая политика может ломать legitimate scripts, а режим Report-Only сообщает о нарушениях, но не блокирует их.

\n

Порядок проверки без ложного зелёного статуса

\n
  1. Выберите одну операцию. Назовите endpoint, метод, asset и изменяемое поле.
  2. Опишите угрозу. Запишите precondition и цену ошибки, если запись пройдёт.
  3. Разложите путь. Перечислите источник запроса, cookie, endpoint, проверки и побочный эффект.
  4. Разделите controls. Для CORS, CSRF, авторизации, валидации и CSP укажите разные interruption.
  5. Сделайте отрицательный запрос. Уберите токен, смените origin или выберите чужой объект в разрешённом стенде.
  6. Проверьте состояние. Статус отказа недостаточен: убедитесь, что asset не изменился.
  7. Проверьте положительную ветку. С действительными данными операция должна пройти согласно контракту.
  8. Сверьте покрытие. Найдите остальные state-changing маршруты, фоновые обработчики и альтернативные протоколы.
  9. Запишите остаток. Перечислите непроверенные браузеры, proxy, cookie-контексты, sinks и каналы.
\n

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

\n

Трассировка threat → control → evidence не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, динамические тесты, SAST, сканирование и независимую оценку. Её задача уже: не дать отчёту расширить узкое наблюдение до утверждения о всей системе.

\n

Пример с cookie относится к state-changing HTTP endpoint. Он не покрывает OAuth callback, WebSocket, GraphQL mutation, загрузку файла, очередь сообщений или действия, авторизованные не cookie, а другим способом. Для каждого канала нужно построить отдельный путь. Для файла дополнительно проверяют тип, содержимое, имя, место хранения и выдачу. Для очереди — отправителя, обработчика, повторы и идемпотентность.

\n

Команды и псевдокод выше не дают разрешения атаковать реальный сервис. Выполняйте проверки только на локальном стенде или при явном разрешении владельца. Если неизвестно, какой middleware обслуживает маршрут, или нет способа проверить состояние после отказа, результат должен быть «нужна отдельная проверка», а не «защищено».

\n

Критерий готового security review

\n

Для выбранной операции читатель должен без устных пояснений увидеть asset, precondition, ordered path, interruption, evidence и residual risk. Отрицательный запрос должен получить ожидаемый отказ до изменения состояния, положительный — пройти по контракту. Запись должна назвать окружение и перечислить, что не проверялось. Только такой результат можно передать следующему инженеру как ограниченное evidence; это не сертификат безопасности всего приложения.

\n

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

\n" }