{ "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Строка «CORS настроен» описывает настройку чтения ответа из браузера, но не отвечает, принял ли сервер изменяющий запрос. HttpOnly не даёт JavaScript прочитать cookie, но браузер по-прежнему может приложить её к запросу. CSP ограничивает разрешённые ресурсы и выполнение скриптов в документе, но не исправляет небезопасный HTML-sink. Даже хороший SAST-анализ говорит только о просмотренных файлах, правилах и версии запуска.
\nОдин и тот же control может быть полезен в одном месте и бесполезен в другом. Поэтому вместо существительного нужен глагол: «сервер отклоняет POST без действительного CSRF-токена до записи в профиль». В этой формулировке видны операция, условие отказа и момент, до которого должен сохраниться asset. Её можно сопоставить с тестом. «CSRF включён» сопоставить не с чем.
\nВозьмём изменение email. Пользователь вошёл в bank.example, браузер хранит cookie сессии, а endpoint принимает POST /api/profile/email. Внешняя страница может попытаться отправить такой запрос с cookie. CORS определяет, может ли внешний origin прочитать ответ через браузерный API. CSRF-защита должна не допустить нежелательное изменение состояния на сервере.
<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 нельзя объявлять заменой аутентификации или авторизации.
Здесь полезно записать путь по шагам: external-page → browser-attaches-cookie → POST-email → profile-changed. CORS относится к чтению ответа, CSRF-проверка — к допуску изменяющего запроса, авторизация — к праву менять конкретный профиль. Если evidence проверяет только заголовок ответа, из него нельзя выводить, что запись не произошла.
Для каждой операции составьте маленькую карточку. asset — изменяемый объект. precondition — условие, при котором путь возможен. attackPath — упорядоченные действия. interruption — точный запрет. evidence — наблюдение, которое можно повторить. Поле notProven защищает от расширения вывода при пересказе.
{\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.
Проверять нужно не только ответ, но и состояние после отказа. Ниже — команда для локального или специально разрешённого тестового сервиса. Подставьте адрес своего стенда и тестовую cookie; домен из примера не является целью для запроса.
\nBASE=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.
Минимальный псевдокод показывает место отказа. В реальном приложении названия функций, хранилище токенов и формат ошибок будут другими.
\napp.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 и не готовая библиотека. Он не показывает, как сравнивать секреты, ротировать токены, проводить логирование или защищать другие каналы. Эти детали нужно проверять по фреймворку и коду конкретного сервиса.
\nEvidence — это не обязательно скриншот. Им может быть HTTP-ответ вместе с проверкой состояния, тестовый лог с correlation id, результат статического анализа с зафиксированным scope или браузерное наблюдение для конкретной политики. У каждого наблюдения должен быть допустимый вывод. Например, ответ 403 на POST без токена подтверждает отказ этой ветки на данном endpoint в данном окружении. Он не подтверждает защиту всех mutations.
Записывайте границу рядом с результатом. «В тесте запрос без токена получил 403, запись не изменилась; не проверены фоновые задачи, соседние маршруты и production proxy» — полезное утверждение. «CSRF закрыт» — уже нет. Точно так же наличие Content-Security-Policy показывает доставку заголовка. Оно не доказывает, что каждый пользовательский фрагмент безопасно закодирован и ни один sink не исполняет данные.
| Симптом | Что он означает | Проверка | Следующее действие |
|---|---|---|---|
| Чужой origin получает CORS error | Браузер ограничил чтение ответа | Проверить, изменилось ли состояние после отдельного POST | Оставить серверную CSRF-проверку, если операция использует cookie |
| Cookie имеет HttpOnly | Скрипт не читает её значение через API браузера | Проверить фактический запрос и серверную проверку намерения | Не выдавать HttpOnly за защиту от CSRF |
| POST без токена вернул 403 | Одна отрицательная ветка остановлена | Сверить asset, endpoint, окружение и состояние после запроса | Записать scope; отдельно проверить другие write-маршруты |
| Токен есть, но меняется чужой профиль | CSRF не заменяет авторизацию | Повторить с объектом другого пользователя | Проверить владельца ресурса до записи |
| CSP есть, но пользовательский HTML исполняется | Защита документа не исправила источник или sink | Проверить вывод, кодирование и response policy для этой страницы | Исправить безопасный рендеринг; CSP оставить дополнительным слоем |
W3C описывает Content Security Policy как механизм управления ресурсами и выполнением, который снижает риск content injection и работает как defense-in-depth. Там же явно сказано, что CSP не заменяет внимательную валидацию входа и кодирование вывода. Поэтому в трассе CSP может прерывать попытку выполнить неразрешённый скрипт, но не доказывает, что строка безопасно попала в HTML, атрибут или DOM-sink.
\nДля одного пользовательского поля нужны разные проверки: допустимый формат на сервере, безопасный контекст вывода и отрицательный тест для конкретной точки рендера. Политика с script-src и nonce относится к браузерному исполнению. Она не даёт права убрать тесты для шаблона или клиентского кода. Если policy меняется, повторно проверьте доставленный заголовок и позитивные сценарии приложения: чрезмерно строгая политика может ломать legitimate scripts, а режим Report-Only сообщает о нарушениях, но не блокирует их.
Трассировка threat → control → evidence не оценивает вероятность атаки, ущерб, exploitability или полноту покрытия. Она не заменяет threat modeling, code review, динамические тесты, SAST, сканирование и независимую оценку. Её задача уже: не дать отчёту расширить узкое наблюдение до утверждения о всей системе.
\nПример с cookie относится к state-changing HTTP endpoint. Он не покрывает OAuth callback, WebSocket, GraphQL mutation, загрузку файла, очередь сообщений или действия, авторизованные не cookie, а другим способом. Для каждого канала нужно построить отдельный путь. Для файла дополнительно проверяют тип, содержимое, имя, место хранения и выдачу. Для очереди — отправителя, обработчика, повторы и идемпотентность.
\nКоманды и псевдокод выше не дают разрешения атаковать реальный сервис. Выполняйте проверки только на локальном стенде или при явном разрешении владельца. Если неизвестно, какой middleware обслуживает маршрут, или нет способа проверить состояние после отказа, результат должен быть «нужна отдельная проверка», а не «защищено».
\nДля выбранной операции читатель должен без устных пояснений увидеть asset, precondition, ordered path, interruption, evidence и residual risk. Отрицательный запрос должен получить ожидаемый отказ до изменения состояния, положительный — пройти по контракту. Запись должна назвать окружение и перечислить, что не проверялось. Только такой результат можно передать следующему инженеру как ограниченное evidence; это не сертификат безопасности всего приложения.
\n