diff --git a/editorial/agent-rewrites/258.json b/editorial/agent-rewrites/258.json index b09e640..45e0f7f 100644 --- a/editorial/agent-rewrites/258.json +++ b/editorial/agent-rewrites/258.json @@ -1,7 +1,7 @@ { "index": 258, "slug": "editorial-2020-11-practice-security-baseline", - "title": "Старая админка без CSRF: как проверить минимальный контур защиты", - "excerpt": "Один state-changing маршрут показывает, где заканчивается сессия, начинаются CSRF и permission, зачем нужен CSP и чем подтверждать отрицательный путь.", - "contentHtml": "

Симптом у старой админки простой: пользователь открывает форму, нажимает «Сохранить», и профиль меняется. Но тот же маршрут принимает запрос без CSRF-токена. В логах виден только действующий сеанс, поэтому команда принимает успешный ответ за доказательство безопасности. Цена ошибки зависит от права пользователя: посторонняя страница может заставить его браузер отправить действие с cookie, а запрос с известной сессией может изменить данные без явного намерения. Для администратора это может означать смену реквизитов, добавление пользователя или изменение настроек.

\n

Тезис статьи короткий: минимальный security baseline строится вокруг конкретного state change. Сначала сервер проверяет, кого он узнал. Затем проверяет контекст формы и право на действие. Только после этого меняет состояние. Cookie-флаги и CSP дополняют этот контур, но не заменяют ни CSRF-проверку, ни серверную авторизацию. Учебный пример ниже не доказывает защиту production-системы. Он показывает модель, которую можно проверить на одном маршруте.

\n

Где возникает ошибка

\n

Рассмотрим учебный маршрут POST /admin/profile. Он меняет только displayName. Браузер отправляет cookie с сессией автоматически. Если сервер решает «сессия есть — разрешаем», запрос с другого сайта выглядит для него почти так же, как запрос из формы админки. Поле Origin помогает проверить источник, а CSRF-токен добавляет значение, которого атакующий сайт не должен знать. Эти проверки относятся к контексту формы. Permission отвечает на другой вопрос: имеет ли распознанный пользователь право менять профиль.

\n

Порядок важен. Сначала отбрасываем неподходящий метод и маршрут. Потом получаем сессию. Для HTML-формы проверяем разрешённый origin и токен. Затем проверяем profile:write. Функция обновления не должна вызываться до последней проверки. Если permission спрятан только в интерфейсе, любой клиент сможет обратиться к endpoint напрямую. Если токен проверяется после update, защита появляется в отчёте, но не в поведении.

\n
\"Связь
Минимальный контур связывает действие с угрозой, контролем и наблюдаемым доказательством. Успешный ответ проверяет доступность функции, а отрицательные запросы проверяют границы.
\n

Один маршрут и несколько независимых контролей

\n

У сессии, CSRF, permission и CSP разные владельцы. Сессия связывает cookie с субъектом. CSRF-контроль проверяет, что state change пришёл в ожидаемом контексте. Permission ограничивает действие субъекта. CSP ограничивает ресурсы, которые браузер может загрузить на странице. Нельзя заменить permission атрибутом HttpOnly или закрыть XSS одной директивой CSP.

\n
function updateProfile(request) {\n  if (request.method !== 'POST' || request.path !== '/admin/profile') {\n    return { status: 404, reason: 'route-not-found' };\n  }\n\n  const session = sessions.find(request.cookies.sid);\n  if (!session) return { status: 401, reason: 'unknown-session' };\n\n  if (request.origin !== 'https://admin.example.test') {\n    return { status: 403, reason: 'unexpected-origin' };\n  }\n\n  if (!csrf.verify(session.id, request.body.csrfToken)) {\n    return { status: 403, reason: 'invalid-csrf-token' };\n  }\n\n  if (!session.permissions.includes('profile:write')) {\n    return { status: 403, reason: 'insufficient-permission' };\n  }\n\n  profileStore.setDisplayName(session.userId, request.body.displayName);\n  return { status: 204 };\n}
\n

Код намеренно похож на обычный серверный псевдокод. В нём нет настоящей библиотеки сессий, способа выдачи токена или базы данных. Это учебный пример: он показывает границу между проверкой и побочным эффектом. В реальном приложении токен должен генерировать и проверять поддерживаемый механизм фреймворка или отдельная проверенная библиотека. Значение из примера нельзя копировать как секрет. Также нужно валидировать и безопасно выводить displayName; CSRF не защищает от XSS и некорректной обработки ввода.

\n

Симптомы и действия

\n
Проверка одного state-changing маршрута
СимптомПричинаПроверкаДействие
Любой POST с cookie получает успехСессия принята за доказательство намеренияОтправить запрос без токена и с чужим origin на тестовом стендеОтклонять запрос до update и добавить отрицательный тест
Проверка права есть только в UIКнопка скрывает действие, но endpoint не авторизует егоВызвать маршрут ролью profile:readПроверять profile:write на сервере
CSRF включён на одной формеЗащита привязана к компоненту, а не к карте измененийПеречислить POST, PUT, PATCH и DELETE, которые меняют состояниеДля каждого маршрута определить контекст и механизм защиты
После CSP страница ломаетсяPolicy добавили без списка реальных ресурсовСравнить нарушения с нужными script, style, image и frameНачать с узкой policy, исправить зависимости и только потом включать enforcement
В отчёте есть cookie flags, но нет факта отказаНастройку приняли за наблюдаемое поведениеПроверить response headers и четыре ветки решенияХранить контракт ответа и положительный/отрицательные сценарии отдельно
\n

Cookie и CSP не должны маскировать главную проверку

\n

Для сессии обычно задают Secure и HttpOnly. Первый ограничивает отправку cookie HTTPS-сценарием, второй не даёт обычному JavaScript прочитать значение. Узкий Path может уменьшить область отправки. Но ни один из этих атрибутов не говорит, что пользователь имеет право на конкретный update. Сервер всё равно должен разобрать сессию и выполнить permission check. Для критического действия могут потребоваться повторная аутентификация или одноразовое подтверждение.

\n

CSP задаёт браузеру список разрешённых источников и может уменьшить последствия инъекции или встраивания страницы. Учебный вариант можно начать так:

\n
Content-Security-Policy:\n  default-src 'self';\n  script-src 'self';\n  object-src 'none';\n  base-uri 'self';\n  frame-ancestors 'none'
\n

Эта policy может сломать inline-скрипты, внешний analytics и embedded widget. Поэтому не добавляйте домены по одной ошибке в консоли. Сначала выпишите ресурсы конкретной страницы, проверьте, нужны ли они активу, и примените policy в режиме наблюдения, если это поддерживает ваша схема развёртывания. CSP не исправляет небезопасное экранирование, валидацию входа или доверие к пользовательскому HTML.

\n

Проверяемый отрицательный путь

\n

Положительный сценарий отвечает только на вопрос «маршрут работает». Для baseline важнее сохранить отказ там, где контроль должен сработать. На учебной среде нужны минимум четыре входа: корректная сессия, origin и токен с правом получают 204; отсутствующий токен получает 403; чужой origin получает 403; роль без profile:write получает 403. Не проверяйте этот пример на чужом сервисе и не используйте настоящие cookie или персональные данные.

\n

Логируйте причину отказа без токена и секретов. Для диагностики достаточно идентификатора маршрута, результата проверки и request id. Не записывайте значение CSRF-токена, session ID или полный body. Если реальный proxy переписывает заголовки, проверяйте также финальный HTTP-ответ разрешённого стенда. Unit-тест функции и проверка ответа веб-сервера доказывают разные вещи.

\n

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

\n
  1. Назовите один актив и один маршрут, который меняет его состояние. Запишите клиентов маршрута: HTML-форма, API или интеграция.
  2. Разделите решения: сессия, контекст формы, permission и response policy. Для каждого укажите вход, владельца и момент проверки.
  3. Поставьте все server-side gates перед вызовом функции, которая меняет данные. Не полагайтесь на скрытую кнопку или проверку в JavaScript.
  4. Подключите CSRF-механизм фреймворка, если он есть. Для собственной схемы отдельно опишите выдачу, срок жизни, привязку и сравнение токена.
  5. Опишите cookie attributes и CSP для реального маршрута. Сверьте policy с ресурсами страницы, браузерами и конфигурацией веб-сервера.
  6. Добавьте положительный тест и отрицательные тесты для пустого токена, чужого origin, неизвестной сессии и недостаточного permission.
  7. Проверьте финальный ответ на разрешённом стенде. Сохраните только безопасные поля: статус, выбранный маршрут, тип отказа и request id.
  8. Запишите непокрытые зоны: XSS, SQL injection, upload, CORS, rate limit, восстановление аккаунта, TLS, секреты, зависимости и сетевой периметр.
\n

Ограничения и критерий готовности

\n

Этот baseline не является полным аудитом. Он не проверяет криптографию, конфигурацию CDN, реальный браузерный cache, доступность cookie на всех поддоменах, API-интеграции или права каждой роли. Origin может отсутствовать в некоторых допустимых контекстах, а один и тот же endpoint может обслуживать разные типы клиентов. В таком случае нельзя бездумно расширять исключения: нужно разделить контракты и описать отдельную модель угрозы.

\n

Работу можно считать готовой для одного маршрута, если документирован актив, а до изменения состояния проходят отдельные проверки сессии, контекста и permission; четыре учебных отрицательных/положительных сценария дают ожидаемые статусы; финальный ответ содержит согласованные cookie и CSP-поля; журнал не раскрывает секреты; команда явно записала непокрытые области. Это проверяемый критерий для узкого участка, а не заявление, что всё приложение защищено.

\n

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

\n" + "title": "Минимальная защита старой админки: один маршрут, четыре контроля", + "excerpt": "Не начинаем с коллекции заголовков: для учебного маршрута связываем актив, угрозу, контроль и наблюдаемое доказательство.", + "contentHtml": "

Симптом у старой админки обычно выглядит безобидно: форма меняет профиль, сессия работает, а после релиза никто не может показать, какие условия действительно защищают это изменение. В настройке лежат несколько headers, в коде есть проверка пользователя, но связь между активом, угрозой и проверкой не записана. Цена такой неопределённости конкретна: команда может добавить ещё один заголовок, оставить state-changing маршрут без server-side permission или принять успешный ответ за доказательство, что посторонний запрос не изменит данные.

\n

В ноябре 2020 года я бы начал не с большого аудита и не с обещания «закрыть web security». Ниже — учебный маршрут POST /admin/profile на вымышленном origin https://admin.example.test. Его предполагаемый эффект — изменение synthetic display name; fixture останавливается раньше, на решении до эффекта. Цель — собрать маленький baseline: известная сессия, токен и origin для state change, permission на сервере, cookie contract и ограничивающий CSP header. Это не pentest, не security scan и не подтверждение production-защиты.

\n

Сначала называем актив и цену ошибки

\n

Актив здесь не «сайт вообще», а возможность изменить профиль из административной формы. Угроза — запрос приходит не в ожидаемом пользовательском сценарии или пользователь с действующей сессией вызывает действие без нужного права. Контроль — не одно условие, а несколько независимых: сервер узнаёт сессию, для формы сравнивает CSRF token и ожидаемый origin, затем проверяет permission перед побочным эффектом. Доказательство — фиксированный отрицательный сценарий, который получает 403, и явный положительный сценарий, который получает 204.

\n

Такое сужение важно. Cookie с HttpOnly полезен для ограничения доступа скрипта к значению cookie, но сам по себе не отвечает на вопрос, можно ли отправить нежелательное действие из браузера. Аналогично CSP ограничивает источники контента на стороне user agent, но не заменяет validation input, encoding output и authorisation в приложении. OWASP ASVS 4.0.2 уже был доступен в октябре 2020 года; я использую его как список проверяемых областей, а не как наклейку «соответствует стандарту».

\n
\"Вертикальная
Baseline становится проверяемым, когда каждому активу сопоставлены не только риск и настройка, но и короткое доказательство. Один header не заменяет permission, а один положительный ответ не доказывает отрицательный путь.
\n

Матрица не даёт потерять границу контроля

\n
Учебная матрица «актив → угроза → контроль → доказательство»
АктивУгроза в узком маршрутеКонтрольЧто считаем доказательством
Изменение display nameзапрос проходит без ожидаемого контекста формысравнить Origin и CSRF token до updatefixture отклоняет foreign origin и пустой token с 403
Сессия администраторазначение cookie доступно скрипту или посылается без HTTPS-контрактаSecure, HttpOnly, узкий Path=/adminучебная карта response headers содержит три атрибута
Право на изменениеаутентифицированный пользователь пишет без ролиserver-side profile:write перед эффектомfixture отклоняет profile:read с 403
Разметка admin pageинъекция загружает неожиданный script или объектvalidation/encoding в приложении плюс CSP2 policyв карте headers есть default-src, object-src и frame-ancestors
\n

В строке про сессию Path — ограничение области отправки cookie, а не граница авторизации. RFC 6265 прямо описывает механизмы Cookie и Set-Cookie; из этого не следует, что cookie attribute заменяет серверную проверку права. В строке про CSP также нет обещания «остановить XSS». W3C CSP Level 2 называет policy дополнительной защитой: сначала приложение корректно работает с input и output, затем browser получает более узкий список допустимых источников.

\n

Один state change должен пройти через все gates

\n

Симптом «форма сработала» ничего не говорит о порядке проверок. Причина ошибки часто в том, что разработчик смешивает наличие сессии с правом на конкретное действие. Проверка должна быть простой: до вызова функции обновления собрать method, path, session, origin, token и permission; затем явно остановить маршрут на первом неподходящем условии. Действие — оставить этот порядок рядом с кодом и не переносить его в неочевидный клиентский обработчик.

\n
// Учебный псевдокод одного HTML-маршрута. Не middleware и не framework API.\nconst decision = evaluateTrainingStateChange({\n  method: 'POST',\n  path: '/admin/profile',\n  sessionId: request.cookie.sid,\n  origin: request.header.origin,\n  csrfToken: request.form.csrf,\n  permission: request.user.permission,\n});\n\nif (!decision.allowed) return response.status(decision.status).end();\nreturn updateTrainingProfile(request.form.displayName).status(204).end();
\n

Это намеренно не готовое middleware и не код какого-либо framework. В учебной форме token передаётся в body, а origin сравнивается только для одного HTML state-changing маршрута. В реальном приложении надо отдельно решить, какие клиенты существуют, когда browser посылает Origin, как выдаётся token, где хранится сессия и как API отличается от формы. Но permission всё равно остаётся серверной границей: клиентский скрытый элемент, URL и кнопка не являются доказательством права.

\n

Headers описывают контракт ответа, а не магическую броню

\n

Симптом у конфигурации другой: header записан в wiki, но никто не знает, на каком route и в каких ответах его ждут. Причина — security policy живёт отдельно от маршрута. Проверка — сделать маленькую карту response headers и привязать её к /admin/, а не включать случайный набор директив глобально. Действие — перед применением сверить syntax с версией веб-сервера, инвентаризировать фактические scripts, styles и frames, а затем проверить ответ разрешённого стенда.

\n
# Учебный фрагмент, не готовая конфигурация production-сервера.\n# admin.example.test — учебный origin; внешний адрес или секрет здесь не нужны.\nlocation /admin/ {\n  add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;\n  add_header Referrer-Policy "same-origin" always;\n  proxy_pass http://training_admin_upstream;\n}\n\n# Атрибуты cookie выставляет приложение после успешной аутентификации:\n# Set-Cookie: sid=...; Path=/admin; Secure; HttpOnly
\n

В примере policy запрещает plugin content, ограничивает base URL и запрещает встраивание страницы в frame. Она также разрешает scripts только с того же origin. Это может сломать старую страницу с inline script, внешним widget или другим asset host. Поэтому фрагмент не надо копировать в production «как есть»: сначала составляют список необходимых ресурсов, фиксируют допустимый риск и проверяют выбранную версию nginx. В статье нет реального proxy, upstream или HTTP-запроса; конфигурация показывает форму контракта, а не выполненную настройку.

\n

Короткий HTTP-след лучше общего заявления

\n

Для маршрута полезно хранить не только код, но и один ожидаемый обмен. Симптом неопределённости — спор о том, должен ли route требовать origin, token или permission. Причина — контроль не выражен наблюдаемыми полями. Проверка — описать разрешённый учебный request и expected response headers. Действие — превратить пару в тест или безопасную ручную проверку на разрешённом стенде, когда такой стенд появится.

\n
POST /admin/profile HTTP/1.1\nHost: admin.example.test\nOrigin: https://admin.example.test\nCookie: sid=training-session-42\nContent-Type: application/x-www-form-urlencoded\n\ndisplayName=Alex&csrf=training-csrf-6fd2\n\nHTTP/1.1 204 No Content\nContent-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'\nSet-Cookie: sid=training-session-42; Path=/admin; Secure; HttpOnly\nReferrer-Policy: same-origin
\n

Здесь нет реального session ID, адреса, пользователя или данных профиля. admin.example.test — учебный origin, а token намеренно короткий и не предназначен для защиты чего-либо вне fixture. Статус 204 означает лишь успешный исход синтетического маршрута. Нельзя из него сделать вывод, что TLS настроен, cookie реально доставляется браузером, CSP принят каждым user agent или сервер выдал headers на ошибке. Эти вопросы относятся к следующей, уже разрешённой проверке окружения.

\n

Fixture удерживает отрицательные сценарии рядом с маршрутом

\n

Симптом регрессии проявится, если после следующей правки один из gates перестанет участвовать в решении. Причина может быть в перестановке middleware, другом имени поля или слишком широком условии. Проверка не требует подключаться к серверу: fixture меняет по одному training input и сравнивает решение. Действие — запускать её вместе с revision и не называть результатом этой команды проверку реального browser, proxy или security perimeter.

\n
# В памяти: сеть, browser, proxy и scanner не запускаются.\nnode web/scripts/upgrade-2020-11.mjs --verify-fixture\n\n# Ищем не уязвимость, а сохранение учебного контракта:\n# expectedRequestAccepted=true\n# foreignOriginRejected=true\n# missingTokenRejected=true\n# insufficientPermissionRejected=true\n# cspContractPresent=true\n# sessionCookieContractPresent=true
\n

В этой fixture foreign origin, пустой token и роль profile:read должны завершиться 403. Успешный synthetic request получает 204. Отдельно проверяется только наличие заданных полей CSP и Set-Cookie в объекте response contract. Если ответ на реальном стенде будет отличаться, это не повод менять fixture под сервер: сначала нужно назвать, какая граница модели отличается, и решить, расширяем ли contract либо обнаружили ошибку rollout.

\n

Нумерованный маршрут для первой правки

\n
  1. Выбрать один state-changing маршрут и назвать актив: какое именно состояние он меняет и кому принадлежит право на это действие.
  2. Записать одну угрозу, которую можно проверить без атаки: foreign origin, пустой token, отсутствующая сессия или недостаточное permission.
  3. Поставить server-side gates до побочного эффекта: session, context формы, token и permission. Не переносить permission в шаблон или JavaScript.
  4. Собрать response contract: cookie attributes для HTTPS-сценария и CSP policy с ограниченной областью. До rollout сверить каждый directive с реальными assets и версией сервера.
  5. Добавить положительный и отрицательные учебные сценарии. Положительный маршрут доказывает только доступность функции; отрицательные доказывают, что конкретные gates действительно останавливают запрос.
  6. Записать verdict и исключения: нет ли API-клиентов, внешних origins, upload, персональных данных, rate limiting или другого актива, который требует отдельной модели угроз.
\n

Граница этой практики

\n

Этот baseline не делает приложение «безопасным». Он не проверяет TLS, password storage, account recovery, file upload, SQL injection, logging, dependency vulnerabilities, rate limit, CORS, реальные браузеры или внешний сетевой периметр. Он также не запускает nginx и не утверждает, что header дошёл до production. Его результат скромнее и полезнее: для одного учебного изменения известны актив, угроза, control и evidence. Следующий инженер сможет расширить матрицу, не выдавая эту первую точку за полный AppSec-процесс.

\n

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

\n

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

\n" }