Files
progcode/editorial/agent-rewrites/256.json
T

8 lines
23 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 256,
"slug": "editorial-2020-11-field-security-baseline",
"title": "CSRF не должен быть обещанием: как проверить защиту административного POST",
"excerpt": "Старый административный POST может принять действие из чужого контекста, даже если в чеклисте написано «CSRF включён». Разбираем механизм, отрицательные проверки и критерий готовности без сканирования живого приложения.",
"contentHtml": "<p>Симптом простой: в release checklist написано «CSRF включён», но никто не может показать запрос, который сервер отклонил из чужого контекста. Старый маршрут <code>POST /admin/profile</code> принимает cookie сессии и обновляет профиль. Форма работает, тест на успешный ответ зелёный, а проверка происхождения запроса отсутствует или живёт только в новом controller. Цена ошибки — не красивый warning. Сайт злоумышленника может заставить браузер администратора отправить нежелательное изменение с его действующей сессией. Ошибка меняет данные до того, как её заметят в журнале.</p>\n<p>Тезис статьи такой: baseline безопасности нужно доказывать не наличием настройки, а независимым отказом на каждом опасном входе. Для одного state-changing маршрута нужно описать контракт запроса, поставить server-side gate до побочного эффекта, отдельно проверить CSRF-токен и источник запроса (Origin/Referer), проверить положительный и отрицательные случаи и лишь затем обсуждать заголовки ответа. Ниже — учебный пример. Он не проверяет живой сайт, не заменяет аудит и не утверждает production-результат.</p>\n<h2>Механизм: cookie подтверждает сессию, но не намерение</h2>\n<p>Браузер автоматически прикладывает cookie сессии к запросам к знакомому сайту. Сервер видит знакомого пользователя, но из одной cookie не узнаёт, сам ли пользователь нажал кнопку. Запрос мог начаться на другом сайте. CSRF-токен добавляет второй сигнал: для stateful-сессии сервер создаёт секретное непредсказуемое значение, а форма или клиент передают его обратно в поле либо заголовке; сервер сравнивает его с ожидаемым значением до изменения состояния. Если состояние токена нельзя хранить, используют подписанный double-submit с привязкой к сессии; простого совпадения cookie и параметра недостаточно.</p>\n<p>Проверка должна находиться на сервере и идти до вызова, который пишет в базу, отправляет платёж, меняет роль или запускает другой необратимый эффект. Проверка в JavaScript не заменяет серверную: скрипт можно не выполнить, изменить или обойти. <code>SameSite</code> у cookie снижает часть cross-site запросов, но это defense-in-depth, а не универсальный контракт CSRF. Атрибут работает по понятию site, включающему схему, а не по строгому совпадению origin: соседний поддомен может оставаться same-site. В режиме <code>Lax</code> cookie всё ещё допускается в некоторых top-level навигациях с безопасным методом, поэтому state-changing действие нельзя прятать за <code>GET</code>. Для критичного POST нужны явные условия метода, сессии, CSRF-токена и полномочия.</p>\n<pre><code>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}</code></pre>\n<p>Это учебный код, а не готовая библиотека. <code>validCsrfToken</code> должна отклонять отсутствующий токен, сравнивать серверное и клиентское значения безопасным способом и иметь понятную область действия. <code>getValidatedSourceOrigin</code> берёт <code>Origin</code>, а при его отсутствии использует только явно разрешённый fallback по <code>Referer</code>; отсутствие обоих заголовков нужно блокировать или отдельно обосновывать политикой. Значение <code>config.adminOrigin</code> задаётся на сервере, а не выводится из непроверенного заголовка. В реальном проекте нужно учесть reverse proxy, смену схемы, несколько доменов и способ доставки формы. Сначала зафиксируйте эти условия. Потом пишите проверку.</p>\n<pre><code>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 }) =&gt; ({\n name,\n ...decideProfileUpdate({ method: 'POST', targetOrigin, ...input })\n})));</code></pre>\n<h2>Что именно нужно доказать</h2>\n<p>У маршрута есть актив: профиль администратора. Есть субъект: пользователь с сессией. Есть действие: изменение профиля. Есть вход: method, path, cookie, origin, token и permission. Есть эффект: запись в хранилище. Такая карта ограничивает проверку. Мы не заявляем, что проверили всё приложение. Мы заявляем только то, что четыре конкретных входа дают ожидаемые решения, а побочный эффект вызывается после gates.</p>\n<p>Положительный случай нужен, чтобы не принять полный запрет за защиту. Сервер должен разрешить запрос с правильным method, доверенным origin, действующей сессией, совпадающим token и правом <code>profile:update</code>. Три отрицательных случая меняют по одному условию. Чужой origin должен получить <code>403</code>. Пустой или неверный token должен получить <code>403</code>. Сессия без write permission тоже должна получить <code>403</code>. Если менять сразу несколько полей, причина решения потеряется.</p>\n<figure><img src=\"/assets/editorial/2020/security-baseline-diagnosis-2020.svg\" alt=\"Схема проверки security baseline: симптом, причина, проверка и действие для одного административного маршрута\"><figcaption>Учебная схема связывает каждый симптом с узкой проверкой и следующим действием. Asset показывает порядок рассуждения, а не результат проверки живого сервера.</figcaption></figure>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><caption>Учебная матрица для <code>POST /admin/profile</code></caption><thead><tr><th>Симптом</th><th>Причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Чужой origin получает успешный ответ</td><td>Origin не входит в gate или проверяется после update</td><td>Подать только <code>Origin: https://other.example.test</code> и ожидать <code>403</code></td><td>Поставить проверку до побочного эффекта и перечислить допустимые origin</td></tr><tr><td>Пустой token проходит</td><td>Поле потерялось между формой и controller или проверка стала необязательной</td><td>Передать пустой <code>csrfToken</code> при прочих правильных полях</td><td>Требовать совпадение с серверным значением; сам token не писать в лог</td></tr><tr><td>Read-only пользователь меняет профиль</td><td>UI-роль приняли за server-side permission</td><td>Оставить сессию действующей, заменить permission на <code>profile:read</code></td><td>Проверять <code>profile:update</code> перед update и покрыть соседние write-маршруты</td></tr><tr><td>Ответ не содержит ожидаемой policy</td><td>Заголовок добавляется только для главной страницы или теряется на proxy</td><td>Проверить один разрешённый ответ на стенде и сравнить поля contract</td><td>Закрепить область заголовка, проверить версию сервера и путь доставки</td></tr></tbody></table>\n<p>Матрица не назначает severity и не выдаёт сертификат соответствия. Она связывает наблюдение с одной гипотезой. Если foreign origin получил <code>403</code> в локальном unit-тесте, это доказывает только решение этой функции на её входе. Оно не доказывает, что reverse proxy пропустит тот же заголовок, что браузер отправит его так же или что другой маршрут не обновляет тот же объект.</p>\n<h2>Контракт запроса и ответа</h2>\n<p>Сначала запишите безопасный учебный trace. Не используйте реальные cookie, токены, адреса пользователей и идентификаторы записей. Пример ниже показывает форму данных и порядок ответа. Он не является командой для запуска против чужого или production-сервера.</p>\n<pre><code>POST /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&amp;displayName=Example</code></pre>\n<pre><code>HTTP/1.1 204 No Content\nContent-Security-Policy: default-src 'self'; frame-ancestors 'none'\nSet-Cookie: sid=TRAINING_SESSION; Path=/; Secure; HttpOnly; SameSite=Lax</code></pre>\n<p>В этом trace значения синтетические. Заголовок <code>Origin</code> — один из входов для проверки контекста и не заменяет token и permission. <code>HttpOnly</code> запрещает JavaScript читать cookie, но cookie всё равно может отправляться с JavaScript-инициированным запросом; <code>Secure</code> ограничивает отправку по схеме <code>https:</code>, а <code>SameSite</code> задаёт правила отправки cookie в cross-site контексте. Эти атрибуты уменьшают поверхность атаки, но не исправляют ошибку авторизации в controller. CSP ограничивает ресурсы и embedding в браузере и помогает с отдельными классами атак, но не заставляет сервер отличать намеренный POST от подделанного.</p>\n<p>Не смешивайте response contract с доказательством доставки. Fixture доказывает только содержимое fixture. Чтобы говорить о стенде, нужно получить фактический ответ через разрешённый канал, учесть proxy и записать версию компонентов. Без этого формулировка должна быть «ожидаемая policy», а не «policy включена».</p>\n<h2>Порядок действий</h2>\n<ol><li>Назовите один актив, один state-changing маршрут и один эффект. Формулировка «проверить весь сайт» слишком широка для первого baseline.</li><li>Опишите положительный request contract: method, path, origin, наличие сессии, CSRF-токен и требуемое permission. Не включайте секреты.</li><li>Проверьте, что каждый gate выполняется на сервере до записи или другого побочного эффекта. Зафиксируйте порядок в коде или тесте.</li><li>Сделайте один разрешённый synthetic request и минимум три отрицательных. В каждом отрицательном случае меняйте только одно условие.</li><li>Проверьте status, отсутствие вызова update для отказанных случаев и безопасный объём логирования. Токены и cookie в логи не попадают.</li><li>Сверьте фактический ответ разрешённого стенда с response contract: <code>Set-Cookie</code>, CSP и область применения заголовков. Не переносите вывод на другие маршруты без отдельной проверки.</li><li>Если отрицательный случай разрешён или побочный эффект вызывается до проверки, остановите выпуск этого маршрута. Сначала верните явный gate, затем повторите весь набор.</li></ol>\n<h2>Как разбирать отрицательный путь</h2>\n<p>Если чужой origin прошёл, не добавляйте CSP в надежде закрыть проблему. CSP управляет поведением браузера, а решение о записи принимает сервер. Найдите место, где controller вызывает update, и поставьте проверку до него. Затем добавьте тест, который меняет только origin. Если тест проходит, он фиксирует именно этот регресс.</p>\n<p>Если пустой token прошёл, проверьте связку формы и controller. Частая ошибка — условие вида «проверить token, если поле присутствует». Для state-changing действия отсутствие поля должно быть отказом. Не исправляйте симптом, отключая проверку для старого клиента. Сначала выясните контракт клиента и выберите явную совместимую миграцию.</p>\n<p>Если read-only пользователь прошёл, ищите владельца полномочия на сервере. Скрытая кнопка в интерфейсе не является authorization. Permission должен проверяться для конкретного действия и конкретного ресурса. Нужен также отрицательный тест для соседнего пользователя, чтобы случайная подмена идентификатора не открыла чужой профиль.</p>\n<p>Если ожидаемый header пропал, отделите генерацию ответа от доставки. Проверьте route, middleware, reverse proxy и кэш. Не называйте policy действующей, пока разрешённый стенд не вернул её фактически. Если header добавляет proxy, проверьте, что внутренний ответ не обходится другим путём. CSP при этом остаётся отдельным browser-side контролем: её наличие не заменяет отказ сервера на чужой origin или неверный токен.</p>\n<h2>Ограничения учебной проверки</h2>\n<p>Этот материал не сканирует приложение и не проверяет CVE. Он не оценивает TLS, пароли, загрузку файлов, CORS, SQL-инъекции, clickjacking во всех браузерах, гонки, восстановление после ошибки или другие endpoints. Он не подтверждает соответствие OWASP ASVS и не даёт production-результат. Synthetic request показывает форму рассуждения и границу теста. Для реального вывода нужны владелец системы, разрешённый стенд, фактический HTTP-ответ, журналы без секретов и отдельный охват остальных маршрутов.</p>\n<p>Есть и технические ограничения. Сравнение origin зависит от схемы, host и proxy-конфигурации. Политика cookie зависит от домена, пути, TLS и сценария авторизации. CSRF-токен должен иметь жизненный цикл, привязанный к выбранной модели сессии. Нельзя копировать код из примера без проверки фреймворка, версии сервера и способа маршрутизации. Учебный фрагмент ограничен намеренно: он показывает механизм, но не скрывает места, где проект должен принять собственное решение.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Baseline для этого маршрута готов, когда положительный synthetic request проходит, каждый отрицательный случай получает ожидаемый отказ, а <code>update</code> не вызывается ни для одного отказа. На разрешённом стенде фактический ответ совпадает с зафиксированным response contract. В записи проверки указаны маршрут, граница вывода, версия компонентов и оставшиеся непроверенные пути. Если хотя бы один из этих пунктов отсутствует, результат нужно назвать частичным.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP: Cross-Site Request Forgery Prevention Cheat Sheet</a> — механизм CSRF и требования к server-side защите.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie\" target=\"_blank\" rel=\"noopener\">MDN: Set-Cookie</a> — атрибуты <code>Secure</code>, <code>HttpOnly</code> и <code>SameSite</code>.</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP\" target=\"_blank\" rel=\"noopener\">MDN: Content Security Policy</a> — область действия CSP и её место среди защитных механизмов.</li></ul>"
}