{ "index": 63, "slug": "editorial-2026-04-practice-modern-web-security", "title": "Безопасность веба начинается с границы: как доказать, что контроль прерывает атаку", "excerpt": "CSP, CSRF-токен и проверка прав решают разные задачи. Разбираем путь атаки, точку прерывания, отрицательный тест и критерий, по которому защиту можно проверить.", "contentHtml": "
Симптом часто выглядит убедительно: сервер отдаёт заголовок CORS, cookie помечена HttpOnly, а endpoint изменения профиля проверяет авторизацию. Но чужая страница всё ещё может отправить POST с cookie пользователя. Команда видит несколько включённых controls и считает задачу закрытой. Цена ошибки — изменение данных от имени жертвы, инцидент без понятной точки отказа и долгий спор о том, какая настройка должна была остановить запрос.
Тезис статьи простой: контроль защищает не «веб вообще», а конкретный переход в маршруте атаки. Для каждой меры нужно назвать вход, условие, точку прерывания и проверяемый результат. CORS ограничивает чтение ответа браузером. CSRF-токен проверяет намерение для state-changing запроса. Проверка прав решает, может ли пользователь выполнить операцию. Эти меры дополняют друг друга, но одна не заменяет другую.
\nНачните с действия нарушителя, а не со списка заголовков. В учебном сценарии пользователь вошёл в приложение, браузер хранит сессионную cookie, а endpoint принимает POST /api/profile/email. Внешняя страница содержит форму или JavaScript, который отправляет запрос на этот адрес. Браузер может приложить cookie к запросу. Если сервер не требует отдельного доказательства намерения, запрос меняет email.
У пути есть четыре наблюдаемые точки: источник запроса, браузер, endpoint и операция записи. CORS не делает внешний POST невозможным. Он обычно мешает прочитать ответ из JavaScript. Это другая граница. HttpOnly не запрещает браузеру отправлять cookie. Он только скрывает cookie от JavaScript. SameSite может уменьшить риск для части кросс-сайтовых запросов, но режим зависит от контекста, способа навигации и политики cookie. Защита должна проверять контракт на сервере.
Аутентификация отвечает на вопрос «кто отправил запрос?». Авторизация отвечает на вопрос «может ли этот пользователь изменить этот объект?». CSRF-защита отвечает на вопрос «есть ли у запроса доказательство, которое внешний сайт не может получить и воспроизвести?». Валидация входа отвечает на вопрос «соответствует ли значение контракту поля?». Нельзя перенести ответ одного слоя на другой.
\nСервер может принять валидный токен CSRF от пользователя без права менять чужой профиль. Тогда токен доказал происхождение запроса, но не право на операцию. Обратный случай тоже возможен: сервер правильно проверяет право, но принимает запрос с cookie без CSRF-защиты. Злоумышленник не получает новые права, но заставляет уже авторизованного пользователя выполнить разрешённое действие.
\nКонтроль становится проверяемым, когда его условие видно в коде и в тесте. Фраза «CORS настроен» не сообщает, что именно проверяли. Формулировка «без заголовка X-CSRF-Token endpoint возвращает 403 и не меняет запись» задаёт границу. Она не доказывает безопасность всех endpoint-ов, но доказывает один отрицательный путь для одной операции.
Ниже — учебный фрагмент на Express-подобном API. Он не подключается к базе, не создаёт настоящую сессию и не показывает production-результат. Функции getSession, findUser и updateEmail обозначают границы приложения. В реальном сервисе их контракты нужно проверить отдельно.
app.post('/api/profile/email', async (req, res) => {\n const session = await getSession(req);\n if (!session) return res.sendStatus(401);\n\n const csrf = req.get('X-CSRF-Token');\n if (!csrf || !timingSafeEqual(csrf, session.csrfToken)) {\n return res.sendStatus(403);\n }\n\n const email = parseEmail(req.body.email);\n if (!email) return res.status(400).json({ error: 'invalid_email' });\n\n const user = await findUser(session.userId);\n if (!user || user.id !== session.userId) return res.sendStatus(403);\n\n await updateEmail(user.id, email);\n return res.sendStatus(204);\n});\nПорядок проверок здесь не является универсальным шаблоном. Он показывает цепочку решений: сессия допускает запрос в контекст пользователя, токен закрывает CSRF-переход, парсер ограничивает значение, а проверка идентификатора не даёт обновить чужую запись. Каждый отказ имеет отдельный статус и тестируемое условие. Если приложение использует другой способ передачи CSRF-токена, сохраняется тот же принцип: внешний сайт не должен получить нужное доказательство из обычной сессии.
\nОтрицательный путь обязателен. Уберите заголовок, оставьте cookie и отправьте тот же POST. Ожидаемый результат — 403, а значение email не изменилось. Если сервер вернул 204 или запись изменилась, CORS, HttpOnly и наличие формы входа не имеют значения: граница endpoint пропускает запрос. В учебном примере это проверка логики, а не свидетельство поведения конкретного production-сервиса.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Есть CORS, но чужая форма меняет данные | CORS ограничивает чтение ответа, а не сам state-changing запрос | Отправить POST без CSRF-токена и проверить статус и запись | Добавить серверную проверку CSRF или иной эквивалентный механизм |
| Cookie имеет HttpOnly | Браузер всё ещё может приложить cookie к запросу | Проверить фактический запрос во внешнем контексте | Не считать HttpOnly защитой от CSRF; оставить его для защиты от чтения cookie скриптом |
| Токен проверяется, но меняется чужой объект | CSRF-токен не заменяет авторизацию | С токеном пользователя запросить объект другого пользователя | Сверить владельца объекта с субъектом сессии и вернуть 403 |
| Валидация поля есть только в браузере | Клиентский код не является доверенной границей | Отправить запрос напрямую с неверным или лишним полем | Повторить валидацию на сервере до записи |
| CSP включена, но XSS-тест проходит | Политика не исправляет небезопасный sink и неверное происхождение HTML | Проверить response header и отрицательный сценарий для конкретного sink | Исправить источник и вывод данных; использовать CSP как дополнительный слой |
Для каждой меры заведите короткую карточку. В поле asset назовите защищаемый объект. В precondition запишите условие, при котором атака возможна. В interruption укажите запрещаемый переход. В evidence положите наблюдение, которое можно повторить. Последнее поле должно иметь границу: оно отвечает только на свой вопрос.
{\n "asset": "profile.email",\n "precondition": "session_cookie_present",\n "attackPath": [\n "external_page",\n "browser_attaches_cookie",\n "POST_profile_email",\n "profile_changed"\n ],\n "interruption": "reject_without_csrf_token",\n "evidence": "same_request_returns_403_and_value_is_unchanged",\n "notProven": [\n "authorization_for_other_objects",\n "all_other_write_endpoints",\n "XSS_protection"\n ]\n}\nТакая запись полезнее поля security: enabled. Она показывает, что именно проверяли и что осталось за пределами проверки. Если evidence не связывает asset, endpoint и отрицательный результат, его нельзя переносить на другой маршрут. Если в карте нет residual risk, это не означает нулевой риск. Это означает, что карту заполнили неполно.
Content Security Policy (CSP) задаёт браузеру правила для ресурсов и выполнения скриптов. Она может уменьшить последствия инъекции контента. Но CSP не знает, имеет ли пользователь право менять email, и не добавляет секретный токен в POST. Поэтому политика может быть полезной дополнительной защитой, но не заменяет серверную проверку намерения и прав.
\nОбратное ограничение тоже важно. Хорошая CSRF-защита не исправляет HTML-инъекцию. Если пользовательский текст попадает в небезопасный DOM-sink, скрипт может выполнить действие уже из доверенного контекста страницы и прочитать доступные данные. В этом случае нужны безопасный вывод, кодирование, ограничения источников и тесты для конкретного sink. Нельзя закрыть XSS, добавив заголовок к endpoint изменения профиля.
\nCSRF-токен защищает конкретный контракт, если сервер действительно проверяет его до изменения состояния. Он не защищает от украденной сессии, вредоносного скрипта внутри доверенного origin или компрометации сервера. SameSite-cookie снижает риск для части сценариев, но её поведение зависит от браузера и контекста. Не делайте из свойства cookie универсальное доказательство.
\nКод примера не покрывает OAuth callback, загрузку файлов, WebSocket, GraphQL mutations и фоновые очереди. У каждого канала свои границы. Для GraphQL нужно проверить mutation и resolver. Для файла — имя, тип, содержимое, место хранения и выдачу. Для очереди — кто помещает сообщение, кто его обрабатывает и повторяется ли операция безопасно.
\nНе объявляйте защиту готовой по одному зелёному тесту. Тест может подтвердить отказ без токена, но не проверит права на чужой объект или другой endpoint. Проверка готова, когда исходный отрицательный запрос возвращает ожидаемый отказ, состояние ресурса не изменяется, а запись связывает результат с конкретным маршрутом и перечисляет непроверенные соседние пути.
\nДля выбранной операции у команды есть карта из asset, precondition, attack path, interruption и evidence. Есть автоматизированный или воспроизводимый тест без нужного доказательства. Он получает отказ, а запись остаётся неизменной. Отдельный тест проверяет права на чужой объект. В документе явно указано, что CORS, HttpOnly и CSP не заменяют эти проверки. Если хотя бы одного пункта нет, результат — не «защищено», а «нужна следующая проверка».
\n