{ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", "excerpt": "К своему профилю API возвращает 200, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.", "contentHtml": "
Представьте endpoint GET /profile?id=.... Пользователь u-1 запрашивает свой профиль и получает 200, затем меняет один идентификатор и получает профиль u-2 с тем же статусом. Интерфейс не показывает кнопку для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого.
Это учебный сценарий, а не отчёт о конкретном инциденте. Его задача — показать, как превратить фразу «авторизация проверена» в воспроизводимое доказательство. Для одного endpoint мы свяжем требование, субъект, действие, объект, ожидаемый HTTP-ответ и фактический отрицательный тест.
\nАутентификация отвечает на вопрос «кто пришёл». Авторизация отвечает на вопрос «может ли этот субъект выполнить действие над этим объектом». Валидная сессия не даёт доступа ко всем записям, а роль сама по себе не доказывает владение объектом.
\nВозьмём требование AUTH-PROFILE-01: пользователь может прочитать свой профиль, но не профиль другого пользователя. В этой статье закрепим учебный HTTP-контракт. Для известного чужого профиля выберем 403, чтобы пример был однозначным; если продукт скрывает существование объекта, команда может выбрать 404, но тогда это значение нужно одинаково записать в policy, adapter и тест.
| Субъект | Действие | Объект | Ожидаемый ответ |
|---|---|---|---|
| user u-1 | read | profile u-1 | 200, доступ разрешён |
| user u-1 | read | profile u-2 | 403, доступ запрещён |
| guest u-1 | read | profile u-1 | 403, роль не разрешена |
| без проверенного субъекта | read | profile u-1 | 401, нужен challenge |
| admin a-1 | read | audit | 200, доступ разрешён |
| user u-1 | read | несуществующий объект | 404, объект не найден |
Статус — часть контракта, а не украшение отчёта. 401 относится к отсутствию действительного контекста аутентификации, 403 — к распознанному запросу без нужного разрешения. 404 означает отсутствие представления или сознательное сокрытие его существования. В тесте нельзя оставлять формулировку «403 или 404»: она не даёт команде проверяемого результата.
\nСхема ниже показывает минимальную петлю доказательства: отрицательный вход должен дойти до assert, а расхождение возвращается в исправление требования или policy.
\nСсылка на объект может быть числом, UUID или slug. Непредсказуемый идентификатор полезен как дополнительная мера, но не заменяет проверку доступа. Если endpoint получает id из URL и сразу делает поиск по нему, пользователь может подставить соседнее значение.
| Поле | Доверенный источник | Опасная подмена |
|---|---|---|
| subjectId | проверенный контекст аутентификации | userId из body или query |
| role | проверенные claims и серверная политика | роль из заголовка клиента |
| action | метод и маршрут endpoint | значение из произвольного поля формы |
| ownerId | запись ресурса и доменное хранилище | ownerId, присланный клиентом |
| tenantId | контекст субъекта и серверная запись | tenant из URL без проверки принадлежности |
Сначала получите субъект из уже проверенного контекста. Затем загрузите ресурс в нужной области данных и определите его владельца на сервере. Поле ownerId из тела запроса не является доказательством владения: клиент может поменять его перед отправкой.
Политика должна разрешать узкие комбинации и отказывать во всём неизвестном. Проверка только роли пропускает горизонтальную границу между двумя пользователями. Проверка только токена отвечает на вопрос аутентификации, но не на вопрос доступа к записи.
\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\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}\nФункция показывает форму решения, а не готовый middleware. Она не проверяет подпись токена, срок сессии, CSRF, tenant, базу, кэш или rate limit. Внутренняя reason помогает диагностике, но клиенту безопаснее отдавать общий класс ошибки без сведений о policy и существовании чужой записи.
В production policy должна применяться для каждого действия, которое принимает ссылку на объект: чтения, изменения, удаления, экспорта и административной операции. Если правило действует только в GET-handler, тот же объект может остаться доступным через PATCH или batch endpoint.
\nUnit-тест чистой функции полезен, но не ловит ошибку в middleware, загрузчике данных, сериализаторе или кэше. Следующий минимальный fixture запускает локальный HTTP-сервер, получает объект из серверной Map и проверяет реальный ответ. Заголовки x-subject и x-role здесь лишь заменяют проверенный контекст для примера; в настоящем сервисе клиент не должен сам определять эти значения.
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}\nСохраните блок в файл authorization-check.mjs и запустите командой node authorization-check.mjs на Node 18 или новее. Он напечатает шесть строк PASS. В ответе нет внутренней причины отказа. На реальном endpoint дополнительно проверьте тело ошибки, заголовки и отсутствие побочного действия, а не только число статуса.
Положительный unit-тест может быть зелёным, даже если реальный маршрут уязвим. Policy могла вернуть deny, а adapter — превратить его в 200. Репозиторий мог загрузить запись другого tenant до проверки. Кэш мог сохранить приватный ответ по ключу profile:42 и отдать его следующему субъекту.
Проверка кэша — отдельный отрицательный сценарий: прогрейте ответ от u-1, повторите тот же запрос от u-2 и сравните статус и тело. Для приватного ответа проще запретить общий кэш; если кэш нужен, его ключ и политика должны учитывать все атрибуты, влияющие на доступ. Это проектное решение нельзя объявить безопасным без теста.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не владельца | u-1 запрашивает объект u-2 напрямую | Добавить object-level deny до выдачи |
| Пользователь читает audit | Роль проверяют по наличию | Сравнить user и admin на одном маршруте | Записать разрешённые resource/action явно |
| Неизвестная роль получает доступ | Ветка по умолчанию разрешает | Подать guest и пустой role | Оставить deny последней веткой |
| UI-тест зелёный, API уязвим | Проверяли скрытую кнопку | Повторить запрос без браузера | Тестировать handler и HTTP-границу |
| После прогрева виден чужой ответ | Ключ кэша не разделяет контекст | Повторить запрос разными субъектами | Разделить ключи или отключить кэш |
Fixture не проверяет настоящий токен, базу, reverse proxy, tenant isolation, CSRF, race condition или сетевую конфигурацию. Он доказывает только заявленный учебный маршрут. Случайный UUID не закрывает IDOR, а зелёный unit-тест не доказывает безопасность реального сервиса. Эти границы нужно назвать в отчёте, чтобы результат не расширился до необоснованного «авторизация безопасна».
\nПроверку можно закрыть, когда есть версия требования, матрица входов, прямой HTTP-тест и фактический результат каждой строки. Владелец получает 200, чужой объект — закреплённый deny-ответ, анонимный запрос — 401 с challenge, неизвестный ресурс — 404. Повтор после кэша не меняет результат между субъектами, а клиент не видит внутреннюю причину policy.
\n