Files

8 lines
21 KiB
JSON
Raw Permalink 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": 13,
"slug": "editorial-2027-08-field-security-capstone",
"title": "Авторизация, которую можно доказать: от симптома до отрицательного теста",
"excerpt": "К своему профилю API возвращает 200, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.",
"contentHtml": "<p>Представьте endpoint <code>GET /profile?id=...</code>. Пользователь u-1 запрашивает свой профиль и получает 200, затем меняет один идентификатор и получает профиль u-2 с тем же статусом. Интерфейс не показывает кнопку для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого.</p>\n<p>Это учебный сценарий, а не отчёт о конкретном инциденте. Его задача — показать, как превратить фразу «авторизация проверена» в воспроизводимое доказательство. Для одного endpoint мы свяжем требование, субъект, действие, объект, ожидаемый HTTP-ответ и фактический отрицательный тест.</p>\n<h2>Сначала фиксируем контракт доступа</h2>\n<p>Аутентификация отвечает на вопрос «кто пришёл». Авторизация отвечает на вопрос «может ли этот субъект выполнить действие над этим объектом». Валидная сессия не даёт доступа ко всем записям, а роль сама по себе не доказывает владение объектом.</p>\n<p>Возьмём требование <code>AUTH-PROFILE-01</code>: пользователь может прочитать свой профиль, но не профиль другого пользователя. В этой статье закрепим учебный HTTP-контракт. Для известного чужого профиля выберем 403, чтобы пример был однозначным; если продукт скрывает существование объекта, команда может выбрать 404, но тогда это значение нужно одинаково записать в policy, adapter и тест.</p>\n<div class=\"table-scroll\"><table><caption>Контракт учебного endpoint</caption><thead><tr><th scope=\"col\">Субъект</th><th scope=\"col\">Действие</th><th scope=\"col\">Объект</th><th scope=\"col\">Ожидаемый ответ</th></tr></thead><tbody><tr><td>user u-1</td><td>read</td><td>profile u-1</td><td>200, доступ разрешён</td></tr><tr><td>user u-1</td><td>read</td><td>profile u-2</td><td>403, доступ запрещён</td></tr><tr><td>guest u-1</td><td>read</td><td>profile u-1</td><td>403, роль не разрешена</td></tr><tr><td>без проверенного субъекта</td><td>read</td><td>profile u-1</td><td>401, нужен challenge</td></tr><tr><td>admin a-1</td><td>read</td><td>audit</td><td>200, доступ разрешён</td></tr><tr><td>user u-1</td><td>read</td><td>несуществующий объект</td><td>404, объект не найден</td></tr></tbody></table></div>\n<p>Статус — часть контракта, а не украшение отчёта. 401 относится к отсутствию действительного контекста аутентификации, 403 — к распознанному запросу без нужного разрешения. 404 означает отсутствие представления или сознательное сокрытие его существования. В тесте нельзя оставлять формулировку «403 или 404»: она не даёт команде проверяемого результата.</p>\n<p>Схема ниже показывает минимальную петлю доказательства: отрицательный вход должен дойти до assert, а расхождение возвращается в исправление требования или policy.</p>\n<figure><img src=\"/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg\" alt=\"Цикл проверки: требование, вход, проверка, результат и исправление расхождения.\" loading=\"lazy\" /><figcaption>Проверяемый результат связывает требование с отрицательным входом; расхождение возвращает нас к policy и тесту.</figcaption></figure>\n<h2>Идентификатор выбирает объект, но не даёт право</h2>\n<p>Ссылка на объект может быть числом, UUID или slug. Непредсказуемый идентификатор полезен как дополнительная мера, но не заменяет проверку доступа. Если endpoint получает <code>id</code> из URL и сразу делает поиск по нему, пользователь может подставить соседнее значение.</p>\n<div class=\"table-scroll\"><table><caption>Откуда брать поля для решения</caption><thead><tr><th scope=\"col\">Поле</th><th scope=\"col\">Доверенный источник</th><th scope=\"col\">Опасная подмена</th></tr></thead><tbody><tr><td>subjectId</td><td>проверенный контекст аутентификации</td><td>userId из body или query</td></tr><tr><td>role</td><td>проверенные claims и серверная политика</td><td>роль из заголовка клиента</td></tr><tr><td>action</td><td>метод и маршрут endpoint</td><td>значение из произвольного поля формы</td></tr><tr><td>ownerId</td><td>запись ресурса и доменное хранилище</td><td>ownerId, присланный клиентом</td></tr><tr><td>tenantId</td><td>контекст субъекта и серверная запись</td><td>tenant из URL без проверки принадлежности</td></tr></tbody></table></div>\n<p>Сначала получите субъект из уже проверенного контекста. Затем загрузите ресурс в нужной области данных и определите его владельца на сервере. Поле <code>ownerId</code> из тела запроса не является доказательством владения: клиент может поменять его перед отправкой.</p>\n<h2>Deny-by-default должен быть явным</h2>\n<p>Политика должна разрешать узкие комбинации и отказывать во всём неизвестном. Проверка только роли пропускает горизонтальную границу между двумя пользователями. Проверка только токена отвечает на вопрос аутентификации, но не на вопрос доступа к записи.</p>\n<pre><code>function decide({ role, subjectId, action, resource }) {\n if (typeof role !== 'string' || typeof subjectId !== 'string' ||\n typeof action !== 'string' || !resource) {\n return { status: 'deny', reason: 'invalid-input' };\n }\n\n if (role === 'admin' &amp;&amp; action === 'read' &amp;&amp; resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n\n if (role === 'user' &amp;&amp; action === 'read' &amp;&amp;\n resource.kind === 'profile' &amp;&amp; resource.ownerId === subjectId) {\n return { status: 'allow', reason: 'object-ownership' };\n }\n\n return { status: 'deny', reason: 'default-deny' };\n}</code></pre>\n<p>Функция показывает форму решения, а не готовый middleware. Она не проверяет подпись токена, срок сессии, CSRF, tenant, базу, кэш или rate limit. Внутренняя <code>reason</code> помогает диагностике, но клиенту безопаснее отдавать общий класс ошибки без сведений о policy и существовании чужой записи.</p>\n<p>В production policy должна применяться для каждого действия, которое принимает ссылку на объект: чтения, изменения, удаления, экспорта и административной операции. Если правило действует только в GET-handler, тот же объект может остаться доступным через PATCH или batch endpoint.</p>\n<h2>Доказательство проходит через HTTP</h2>\n<p>Unit-тест чистой функции полезен, но не ловит ошибку в middleware, загрузчике данных, сериализаторе или кэше. Следующий минимальный fixture запускает локальный HTTP-сервер, получает объект из серверной <code>Map</code> и проверяет реальный ответ. Заголовки <code>x-subject</code> и <code>x-role</code> здесь лишь заменяют проверенный контекст для примера; в настоящем сервисе клиент не должен сам определять эти значения.</p>\n<pre><code>import { createServer } from 'node:http';\nimport assert from 'node:assert/strict';\n\nconst profiles = new Map([\n ['u-1', { ownerId: 'u-1' }],\n ['u-2', { ownerId: 'u-2' }],\n]);\n\nfunction decide({ role, subjectId, action, resource }) {\n if (typeof role !== 'string' || typeof subjectId !== 'string' ||\n typeof action !== 'string' || !resource) {\n return { status: 'deny', reason: 'invalid-input' };\n }\n if (role === 'admin' &amp;&amp; action === 'read' &amp;&amp; resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n if (role === 'user' &amp;&amp; action === 'read' &amp;&amp;\n resource.kind === 'profile' &amp;&amp; resource.ownerId === subjectId) {\n return { status: 'allow', reason: 'object-ownership' };\n }\n return { status: 'deny', reason: 'default-deny' };\n}\n\nconst server = createServer((request, response) =&gt; {\n const url = new URL(request.url, 'http://local');\n const id = url.searchParams.get('id');\n const profile = profiles.get(id);\n const resource = id === 'audit'\n ? { kind: 'audit' }\n : profile &amp;&amp; { kind: 'profile', ownerId: profile.ownerId };\n const subjectId = typeof request.headers['x-subject'] === 'string'\n ? request.headers['x-subject']\n : undefined;\n const role = typeof request.headers['x-role'] === 'string'\n ? request.headers['x-role']\n : undefined;\n const action = request.method === 'GET' ? 'read' : 'unknown';\n const decision = decide({ role, subjectId, action, resource });\n const status = !subjectId ? 401\n : !resource ? 404\n : decision.status === 'allow' ? 200\n : 403;\n\n response.writeHead(status, {\n 'content-type': 'application/json',\n ...(status === 401 ? { 'www-authenticate': 'Bearer' } : {}),\n });\n response.end(JSON.stringify({ allowed: decision.status === 'allow' }));\n});\n\nawait new Promise((resolve) =&gt; server.listen(0, '127.0.0.1', resolve));\nconst { port } = server.address();\nconst cases = [\n { name: 'owner', path: '/profile?id=u-1', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 200 },\n { name: 'foreign profile', path: '/profile?id=u-2', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 403 },\n { name: 'unknown role', path: '/profile?id=u-1', headers: { 'x-subject': 'u-1', 'x-role': 'guest' }, status: 403 },\n { name: 'unknown object', path: '/profile?id=u-9', headers: { 'x-subject': 'u-1', 'x-role': 'user' }, status: 404 },\n { name: 'anonymous', path: '/profile?id=u-1', headers: {}, status: 401 },\n { name: 'admin audit', path: '/profile?id=audit', headers: { 'x-subject': 'a-1', 'x-role': 'admin' }, status: 200 },\n];\n\ntry {\n for (const test of cases) {\n const response = await fetch('http://127.0.0.1:' + port + test.path, { headers: test.headers });\n assert.equal(response.status, test.status, test.name);\n console.log('PASS', test.name);\n }\n} finally {\n server.close();\n}</code></pre>\n<p>Сохраните блок в файл <code>authorization-check.mjs</code> и запустите командой <code>node authorization-check.mjs</code> на Node 18 или новее. Он напечатает шесть строк <code>PASS</code>. В ответе нет внутренней причины отказа. На реальном endpoint дополнительно проверьте тело ошибки, заголовки и отсутствие побочного действия, а не только число статуса.</p>\n<h2>Где ломается зелёный тест</h2>\n<p>Положительный unit-тест может быть зелёным, даже если реальный маршрут уязвим. Policy могла вернуть deny, а adapter — превратить его в 200. Репозиторий мог загрузить запись другого tenant до проверки. Кэш мог сохранить приватный ответ по ключу <code>profile:42</code> и отдать его следующему субъекту.</p>\n<p>Проверка кэша — отдельный отрицательный сценарий: прогрейте ответ от u-1, повторите тот же запрос от u-2 и сравните статус и тело. Для приватного ответа проще запретить общий кэш; если кэш нужен, его ключ и политика должны учитывать все атрибуты, влияющие на доступ. Это проектное решение нельзя объявить безопасным без теста.</p>\n<div class=\"table-scroll\"><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Чужой профиль отвечает 200</td><td>Проверили токен, но не владельца</td><td>u-1 запрашивает объект u-2 напрямую</td><td>Добавить object-level deny до выдачи</td></tr><tr><td>Пользователь читает audit</td><td>Роль проверяют по наличию</td><td>Сравнить user и admin на одном маршруте</td><td>Записать разрешённые resource/action явно</td></tr><tr><td>Неизвестная роль получает доступ</td><td>Ветка по умолчанию разрешает</td><td>Подать guest и пустой role</td><td>Оставить deny последней веткой</td></tr><tr><td>UI-тест зелёный, API уязвим</td><td>Проверяли скрытую кнопку</td><td>Повторить запрос без браузера</td><td>Тестировать handler и HTTP-границу</td></tr><tr><td>После прогрева виден чужой ответ</td><td>Ключ кэша не разделяет контекст</td><td>Повторить запрос разными субъектами</td><td>Разделить ключи или отключить кэш</td></tr></tbody></table></div>\n<h2>Порядок проверки</h2>\n<ol><li>Выберите один endpoint, который принимает ссылку на объект, и присвойте требованию стабильный идентификатор.</li><li>Запишите для него субъект, действие, объект и точный внешний ответ; не оставляйте «403 или 404».</li><li>Проверьте источник каждого поля: subject и role из доверенного контекста, owner и tenant из серверной записи, action из маршрута.</li><li>Создайте два изолированных тестовых субъекта и по одному объекту каждого. Не используйте реальные персональные данные.</li><li>Добавьте allow для владельца и deny для чужого объекта, неподходящей роли, неизвестного объекта и недействительной аутентификации.</li><li>Запустите policy-тест, затем прямой HTTP-тест без UI. Сохраните имя кейса, статус, безопасное тело ответа и отсутствие side effect.</li><li>Повторите чтение после прогрева кэша и отдельно проверьте update, delete, export или batch, если они принимают тот же идентификатор.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Fixture не проверяет настоящий токен, базу, reverse proxy, tenant isolation, CSRF, race condition или сетевую конфигурацию. Он доказывает только заявленный учебный маршрут. Случайный UUID не закрывает IDOR, а зелёный unit-тест не доказывает безопасность реального сервиса. Эти границы нужно назвать в отчёте, чтобы результат не расширился до необоснованного «авторизация безопасна».</p>\n<p>Проверку можно закрыть, когда есть версия требования, матрица входов, прямой HTTP-тест и фактический результат каждой строки. Владелец получает 200, чужой объект — закреплённый deny-ответ, анонимный запрос — 401 с challenge, неизвестный ресурс — 404. Повтор после кэша не меняет результат между субъектами, а клиент не видит внутреннюю причину policy.</p>\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Authorization Cheat Sheet</a> — рекомендации по deny by default, проверке каждого запроса, object-level authorization и unit/integration-тестам. Это руководство, а не доказательство контроля в конкретном сервисе.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Insecure Direct Object Reference Prevention Cheat Sheet</a> — объясняет IDOR и проверку доступа для нескольких пользователей и операций. Непредсказуемый идентификатор не заменяет object-level check.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110, HTTP Semantics</a> — определяет смысл 401, 403 и 404; выбор конкретного статуса для чужого объекта остаётся частью контракта приложения.</li><li><a href=\"https://owasp.org/www-project-application-security-verification-standard/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Application Security Verification Standard</a> — страница проекта указывает стабильную версию 5.0.0 и формат версионируемых требований. ASVS помогает сформулировать проверку, но не является сертификатом и не подтверждает состояние конкретного endpoint.</li></ul>"
}