diff --git a/editorial/agent-rewrites/256.json b/editorial/agent-rewrites/256.json index e6b82be..4c04e95 100644 --- a/editorial/agent-rewrites/256.json +++ b/editorial/agent-rewrites/256.json @@ -3,5 +3,5 @@ "slug": "editorial-2020-11-field-security-baseline", "title": "CSRF не должен быть обещанием: как проверить защиту административного POST", "excerpt": "Старый административный POST может принять действие из чужого контекста, даже если в чеклисте написано «CSRF включён». Разбираем механизм, отрицательные проверки и критерий готовности без сканирования живого приложения.", - "contentHtml": "
Симптом простой: в release checklist написано «CSRF включён», но никто не может показать запрос, который сервер отклонил из чужого контекста. Старый маршрут POST /admin/profile принимает cookie сессии и обновляет профиль. Форма работает, тест на успешный ответ зелёный, а проверка происхождения запроса отсутствует или живёт только в новом controller. Цена ошибки — не красивый warning. Сайт злоумышленника может заставить браузер администратора отправить нежелательное изменение с его действующей сессией. Ошибка меняет данные до того, как её заметят в журнале.
Тезис статьи такой: baseline безопасности нужно доказывать не наличием настройки, а независимым отказом на каждом опасном входе. Для одного state-changing маршрута достаточно описать контракт запроса, поставить server-side gate до побочного эффекта, проверить положительный и отрицательные случаи и отдельно подтвердить заголовки ответа. Ниже — учебный пример. Он не проверяет живой сайт, не заменяет аудит и не утверждает production-результат.
\nБраузер автоматически прикладывает cookie сессии к запросам к знакомому сайту. Сервер видит знакомого пользователя, но из одной cookie не узнаёт, сам ли пользователь нажал кнопку. Запрос мог начаться на другом сайте. CSRF-токен добавляет второй сигнал: форма или клиент получают значение через доверенный контекст, а сервер сравнивает его с ожидаемым значением до изменения состояния.
\nПроверка должна находиться на сервере и идти до вызова, который пишет в базу, отправляет платёж, меняет роль или запускает другой необратимый эффект. Проверка в JavaScript не заменяет серверную: скрипт можно не выполнить, изменить или обойти. SameSite у cookie снижает часть cross-site запросов, но не превращает маршрут в универсально защищённый контракт. Для критичного действия нужны явные условия метода, сессии, CSRF-токена и полномочия.
async function updateProfile(request, response) {\n if (request.method !== 'POST') {\n return response.status(405).end();\n }\n\n const session = await readSession(request);\n if (!session) {\n return response.status(401).end();\n }\n\n if (!sameOrigin(request) || !validCsrfToken(request, session)) {\n return response.status(403).end();\n }\n\n if (!session.permissions.includes('profile:update')) {\n return response.status(403).end();\n }\n\n await profiles.update(session.userId, request.body);\n return response.status(204).end();\n}\nЭто учебный код, а не готовая библиотека. Функция validCsrfToken должна сравнивать значения безопасным способом и иметь понятную область действия. sameOrigin не должен доверять произвольному заголовку от клиента без политики приложения. В реальном проекте нужно учесть reverse proxy, смену схемы, несколько доменов и способ доставки формы. Сначала зафиксируйте эти условия. Потом пишите проверку.
У маршрута есть актив: профиль администратора. Есть субъект: пользователь с сессией. Есть действие: изменение профиля. Есть вход: method, path, cookie, origin, token и permission. Есть эффект: запись в хранилище. Такая карта ограничивает проверку. Мы не заявляем, что проверили всё приложение. Мы заявляем только то, что четыре конкретных входа дают ожидаемые решения, а побочный эффект вызывается после gates.
\nПоложительный случай нужен, чтобы не принять полный запрет за защиту. Сервер должен разрешить запрос с правильным method, доверенным origin, действующей сессией, совпадающим token и правом profile:update. Три отрицательных случая меняют по одному условию. Чужой origin должен получить 403. Пустой или неверный token должен получить 403. Сессия без write permission тоже должна получить 403. Если менять сразу несколько полей, причина решения потеряется.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой origin получает успешный ответ | Origin не входит в gate или проверяется после update | Подать только Origin: https://other.example.test и ожидать 403 | Поставить проверку до побочного эффекта и перечислить допустимые origin |
| Пустой token проходит | Поле потерялось между формой и controller или проверка стала необязательной | Передать пустой csrfToken при прочих правильных полях | Требовать совпадение с серверным значением; сам token не писать в лог |
| Read-only пользователь меняет профиль | UI-роль приняли за server-side permission | Оставить сессию действующей, заменить permission на profile:read | Проверять profile:update перед update и покрыть соседние write-маршруты |
| Ответ не содержит ожидаемой policy | Заголовок добавляется только для главной страницы или теряется на proxy | Проверить один разрешённый ответ на стенде и сравнить поля contract | Закрепить область заголовка, проверить версию сервера и путь доставки |
Матрица не назначает severity и не выдаёт сертификат соответствия. Она связывает наблюдение с одной гипотезой. Если foreign origin получил 403 в локальном unit-тесте, это доказывает только решение этой функции на её входе. Оно не доказывает, что reverse proxy пропустит тот же заголовок, что браузер отправит его так же или что другой маршрут не обновляет тот же объект.
Сначала запишите безопасный учебный trace. Не используйте реальные cookie, токены, адреса пользователей и идентификаторы записей. Пример ниже показывает форму данных и порядок ответа. Он не является командой для запуска против чужого или production-сервера.
\nPOST /admin/profile HTTP/1.1\nHost: admin.example.test\nOrigin: https://admin.example.test\nCookie: sid=TRAINING_SESSION\nContent-Type: application/x-www-form-urlencoded\n\ncsrfToken=TRAINING_TOKEN&displayName=Example\nHTTP/1.1 204 No Content\nContent-Security-Policy: default-src 'self'; frame-ancestors 'none'\nSet-Cookie: sid=TRAINING_SESSION; Path=/; Secure; HttpOnly; SameSite=Lax\nВ этом trace значения синтетические. Заголовок Origin помогает определить контекст запроса, но не заменяет token и permission. HttpOnly ограничивает доступ к cookie из JavaScript, Secure требует защищённого транспорта, а SameSite задаёт правила отправки cookie. Эти атрибуты уменьшают поверхность атаки, но не исправляют ошибку авторизации в controller. CSP ограничивает ресурсы, которые браузер может загрузить, и помогает с отдельными классами атак. Она не заставляет сервер отличать намеренный POST от подделанного.
Не смешивайте response contract с доказательством доставки. Если header есть в fixture, он есть в fixture. Чтобы говорить о стенде, нужно получить один фактический ответ через разрешённый канал, учесть proxy и записать версию компонентов. Без этого формулировка должна быть «ожидаемая policy», а не «policy включена».
\nSet-Cookie, CSP и область применения заголовков. Не переносите вывод на другие маршруты без отдельной проверки.Если чужой origin прошёл, не добавляйте CSP в надежде закрыть проблему. CSP управляет поведением браузера, а решение о записи принимает сервер. Найдите место, где controller вызывает update, и поставьте проверку до него. Затем добавьте тест, который меняет только origin. Если тест проходит, он фиксирует именно этот регресс.
\nЕсли пустой token прошёл, проверьте связку формы и controller. Частая ошибка — условие вида «проверить token, если поле присутствует». Для state-changing действия отсутствие поля должно быть отказом. Не исправляйте симптом, отключая проверку для старого клиента. Сначала выясните контракт клиента и выберите явную совместимую миграцию.
\nЕсли read-only пользователь прошёл, ищите владельца полномочия на сервере. Скрытая кнопка в интерфейсе не является authorization. Permission должен проверяться для конкретного действия и конкретного ресурса. Нужен также отрицательный тест для соседнего пользователя, чтобы случайная подмена идентификатора не открыла чужой профиль.
\nЕсли ожидаемый header пропал, отделите генерацию ответа от доставки. Проверьте route, middleware, reverse proxy и кэш. Не называйте policy действующей, пока разрешённый стенд не вернул её фактически. Если header добавляет proxy, проверьте, что внутренний ответ не обходится другим путём.
\nЭтот материал не сканирует приложение и не проверяет CVE. Он не оценивает TLS, пароли, загрузку файлов, CORS, SQL-инъекции, clickjacking во всех браузерах, гонки, восстановление после ошибки или другие endpoints. Он не подтверждает соответствие OWASP ASVS и не даёт production-результат. Synthetic request показывает форму рассуждения и границу теста. Для реального вывода нужны владелец системы, разрешённый стенд, фактический HTTP-ответ, журналы без секретов и отдельный охват остальных маршрутов.
\nЕсть и технические ограничения. Сравнение origin зависит от схемы, host и proxy-конфигурации. Политика cookie зависит от домена, пути, TLS и сценария авторизации. CSRF-токен должен иметь жизненный цикл, привязанный к выбранной модели сессии. Нельзя копировать код из примера без проверки фреймворка, версии сервера и способа маршрутизации. Учебный фрагмент ограничен намеренно: он показывает механизм, но не скрывает места, где проект должен принять собственное решение.
\nBaseline для этого маршрута готов, когда положительный synthetic request проходит, каждый отрицательный случай получает ожидаемый отказ, а update не вызывается ни для одного отказа. На разрешённом стенде фактический ответ совпадает с зафиксированным response contract. В записи проверки указаны маршрут, граница вывода, версия компонентов и оставшиеся непроверенные пути. Если хотя бы один из этих пунктов отсутствует, результат нужно назвать частичным.
Secure, HttpOnly и SameSite.Симптом простой: в release checklist написано «CSRF включён», но никто не может показать запрос, который сервер отклонил из чужого контекста. Старый маршрут POST /admin/profile принимает cookie сессии и обновляет профиль. Форма работает, тест на успешный ответ зелёный, а проверка происхождения запроса отсутствует или живёт только в новом controller. Цена ошибки — не красивый warning. Сайт злоумышленника может заставить браузер администратора отправить нежелательное изменение с его действующей сессией. Ошибка меняет данные до того, как её заметят в журнале.
Тезис статьи такой: baseline безопасности нужно доказывать не наличием настройки, а независимым отказом на каждом опасном входе. Для одного state-changing маршрута нужно описать контракт запроса, поставить server-side gate до побочного эффекта, отдельно проверить CSRF-токен и источник запроса (Origin/Referer), проверить положительный и отрицательные случаи и лишь затем обсуждать заголовки ответа. Ниже — учебный пример. Он не проверяет живой сайт, не заменяет аудит и не утверждает production-результат.
\nБраузер автоматически прикладывает cookie сессии к запросам к знакомому сайту. Сервер видит знакомого пользователя, но из одной cookie не узнаёт, сам ли пользователь нажал кнопку. Запрос мог начаться на другом сайте. CSRF-токен добавляет второй сигнал: для stateful-сессии сервер создаёт секретное непредсказуемое значение, а форма или клиент передают его обратно в поле либо заголовке; сервер сравнивает его с ожидаемым значением до изменения состояния. Если состояние токена нельзя хранить, используют подписанный double-submit с привязкой к сессии; простого совпадения cookie и параметра недостаточно.
\nПроверка должна находиться на сервере и идти до вызова, который пишет в базу, отправляет платёж, меняет роль или запускает другой необратимый эффект. Проверка в JavaScript не заменяет серверную: скрипт можно не выполнить, изменить или обойти. SameSite у cookie снижает часть cross-site запросов, но это defense-in-depth, а не универсальный контракт CSRF. Атрибут работает по понятию site, включающему схему, а не по строгому совпадению origin: соседний поддомен может оставаться same-site. В режиме Lax cookie всё ещё допускается в некоторых top-level навигациях с безопасным методом, поэтому state-changing действие нельзя прятать за GET. Для критичного POST нужны явные условия метода, сессии, CSRF-токена и полномочия.
async function updateProfile(request, response) {\n if (request.method !== 'POST') {\n return response.status(405).end();\n }\n\n const session = await readSession(request);\n if (!session) {\n return response.status(401).end();\n }\n\n const sourceOrigin = getValidatedSourceOrigin(request);\n if (sourceOrigin !== config.adminOrigin) {\n return response.status(403).end();\n }\n\n if (!validCsrfToken(request, session)) {\n return response.status(403).end();\n }\n\n if (!session.permissions.includes('profile:update')) {\n return response.status(403).end();\n }\n\n await profiles.update(session.userId, request.body);\n return response.status(204).end();\n}\nЭто учебный код, а не готовая библиотека. validCsrfToken должна отклонять отсутствующий токен, сравнивать серверное и клиентское значения безопасным способом и иметь понятную область действия. getValidatedSourceOrigin берёт Origin, а при его отсутствии использует только явно разрешённый fallback по Referer; отсутствие обоих заголовков нужно блокировать или отдельно обосновывать политикой. Значение config.adminOrigin задаётся на сервере, а не выводится из непроверенного заголовка. В реальном проекте нужно учесть reverse proxy, смену схемы, несколько доменов и способ доставки формы. Сначала зафиксируйте эти условия. Потом пишите проверку.
function decideProfileUpdate({ method, sourceOrigin, targetOrigin, csrfValid, permissions }) {\n if (method !== 'POST') return { status: 405, shouldUpdate: false };\n if (sourceOrigin !== targetOrigin) return { status: 403, shouldUpdate: false };\n if (csrfValid !== true) return { status: 403, shouldUpdate: false };\n if (!permissions.includes('profile:update')) {\n return { status: 403, shouldUpdate: false };\n }\n return { status: 204, shouldUpdate: true };\n}\n\nconst targetOrigin = 'https://admin.example.test';\nconst cases = [\n { name: 'allow', sourceOrigin: targetOrigin, csrfValid: true, permissions: ['profile:update'] },\n { name: 'foreign-origin', sourceOrigin: 'https://other.example.test', csrfValid: true, permissions: ['profile:update'] },\n { name: 'missing-token', sourceOrigin: targetOrigin, csrfValid: false, permissions: ['profile:update'] },\n { name: 'read-only', sourceOrigin: targetOrigin, csrfValid: true, permissions: ['profile:read'] }\n];\n\nconsole.table(cases.map(({ name, ...input }) => ({\n name,\n ...decideProfileUpdate({ method: 'POST', targetOrigin, ...input })\n})));\nУ маршрута есть актив: профиль администратора. Есть субъект: пользователь с сессией. Есть действие: изменение профиля. Есть вход: method, path, cookie, origin, token и permission. Есть эффект: запись в хранилище. Такая карта ограничивает проверку. Мы не заявляем, что проверили всё приложение. Мы заявляем только то, что четыре конкретных входа дают ожидаемые решения, а побочный эффект вызывается после gates.
\nПоложительный случай нужен, чтобы не принять полный запрет за защиту. Сервер должен разрешить запрос с правильным method, доверенным origin, действующей сессией, совпадающим token и правом profile:update. Три отрицательных случая меняют по одному условию. Чужой origin должен получить 403. Пустой или неверный token должен получить 403. Сессия без write permission тоже должна получить 403. Если менять сразу несколько полей, причина решения потеряется.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой origin получает успешный ответ | Origin не входит в gate или проверяется после update | Подать только Origin: https://other.example.test и ожидать 403 | Поставить проверку до побочного эффекта и перечислить допустимые origin |
| Пустой token проходит | Поле потерялось между формой и controller или проверка стала необязательной | Передать пустой csrfToken при прочих правильных полях | Требовать совпадение с серверным значением; сам token не писать в лог |
| Read-only пользователь меняет профиль | UI-роль приняли за server-side permission | Оставить сессию действующей, заменить permission на profile:read | Проверять profile:update перед update и покрыть соседние write-маршруты |
| Ответ не содержит ожидаемой policy | Заголовок добавляется только для главной страницы или теряется на proxy | Проверить один разрешённый ответ на стенде и сравнить поля contract | Закрепить область заголовка, проверить версию сервера и путь доставки |
Матрица не назначает severity и не выдаёт сертификат соответствия. Она связывает наблюдение с одной гипотезой. Если foreign origin получил 403 в локальном unit-тесте, это доказывает только решение этой функции на её входе. Оно не доказывает, что reverse proxy пропустит тот же заголовок, что браузер отправит его так же или что другой маршрут не обновляет тот же объект.
Сначала запишите безопасный учебный trace. Не используйте реальные cookie, токены, адреса пользователей и идентификаторы записей. Пример ниже показывает форму данных и порядок ответа. Он не является командой для запуска против чужого или production-сервера.
\nPOST /admin/profile HTTP/1.1\nHost: admin.example.test\nOrigin: https://admin.example.test\nCookie: sid=TRAINING_SESSION\nContent-Type: application/x-www-form-urlencoded\n\ncsrfToken=TRAINING_TOKEN&displayName=Example\nHTTP/1.1 204 No Content\nContent-Security-Policy: default-src 'self'; frame-ancestors 'none'\nSet-Cookie: sid=TRAINING_SESSION; Path=/; Secure; HttpOnly; SameSite=Lax\nВ этом trace значения синтетические. Заголовок Origin — один из входов для проверки контекста и не заменяет token и permission. HttpOnly запрещает JavaScript читать cookie, но cookie всё равно может отправляться с JavaScript-инициированным запросом; Secure ограничивает отправку по схеме https:, а SameSite задаёт правила отправки cookie в cross-site контексте. Эти атрибуты уменьшают поверхность атаки, но не исправляют ошибку авторизации в controller. CSP ограничивает ресурсы и embedding в браузере и помогает с отдельными классами атак, но не заставляет сервер отличать намеренный POST от подделанного.
Не смешивайте response contract с доказательством доставки. Fixture доказывает только содержимое fixture. Чтобы говорить о стенде, нужно получить фактический ответ через разрешённый канал, учесть proxy и записать версию компонентов. Без этого формулировка должна быть «ожидаемая policy», а не «policy включена».
\nSet-Cookie, CSP и область применения заголовков. Не переносите вывод на другие маршруты без отдельной проверки.Если чужой origin прошёл, не добавляйте CSP в надежде закрыть проблему. CSP управляет поведением браузера, а решение о записи принимает сервер. Найдите место, где controller вызывает update, и поставьте проверку до него. Затем добавьте тест, который меняет только origin. Если тест проходит, он фиксирует именно этот регресс.
\nЕсли пустой token прошёл, проверьте связку формы и controller. Частая ошибка — условие вида «проверить token, если поле присутствует». Для state-changing действия отсутствие поля должно быть отказом. Не исправляйте симптом, отключая проверку для старого клиента. Сначала выясните контракт клиента и выберите явную совместимую миграцию.
\nЕсли read-only пользователь прошёл, ищите владельца полномочия на сервере. Скрытая кнопка в интерфейсе не является authorization. Permission должен проверяться для конкретного действия и конкретного ресурса. Нужен также отрицательный тест для соседнего пользователя, чтобы случайная подмена идентификатора не открыла чужой профиль.
\nЕсли ожидаемый header пропал, отделите генерацию ответа от доставки. Проверьте route, middleware, reverse proxy и кэш. Не называйте policy действующей, пока разрешённый стенд не вернул её фактически. Если header добавляет proxy, проверьте, что внутренний ответ не обходится другим путём. CSP при этом остаётся отдельным browser-side контролем: её наличие не заменяет отказ сервера на чужой origin или неверный токен.
\nЭтот материал не сканирует приложение и не проверяет CVE. Он не оценивает TLS, пароли, загрузку файлов, CORS, SQL-инъекции, clickjacking во всех браузерах, гонки, восстановление после ошибки или другие endpoints. Он не подтверждает соответствие OWASP ASVS и не даёт production-результат. Synthetic request показывает форму рассуждения и границу теста. Для реального вывода нужны владелец системы, разрешённый стенд, фактический HTTP-ответ, журналы без секретов и отдельный охват остальных маршрутов.
\nЕсть и технические ограничения. Сравнение origin зависит от схемы, host и proxy-конфигурации. Политика cookie зависит от домена, пути, TLS и сценария авторизации. CSRF-токен должен иметь жизненный цикл, привязанный к выбранной модели сессии. Нельзя копировать код из примера без проверки фреймворка, версии сервера и способа маршрутизации. Учебный фрагмент ограничен намеренно: он показывает механизм, но не скрывает места, где проект должен принять собственное решение.
\nBaseline для этого маршрута готов, когда положительный synthetic request проходит, каждый отрицательный случай получает ожидаемый отказ, а update не вызывается ни для одного отказа. На разрешённом стенде фактический ответ совпадает с зафиксированным response contract. В записи проверки указаны маршрут, граница вывода, версия компонентов и оставшиеся непроверенные пути. Если хотя бы один из этих пунктов отсутствует, результат нужно назвать частичным.
Secure, HttpOnly и SameSite.