8 lines
21 KiB
JSON
8 lines
21 KiB
JSON
{
|
||
"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' && action === 'read' && resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' && 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' && action === 'read' && resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' && 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) => {\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 && { 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) => 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>"
|
||
}
|