{ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", "excerpt": "Как связать security-требование, субъекта, объект и действие, а затем доказать на HTTP-границе, что чужой идентификатор не даёт доступ.", "contentHtml": "
Пользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.
\nРазберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.
\nАутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает id из URL, а затем делает поиск только по этому id, он может вернуть запись, которая субъекту не принадлежит.
Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать GET /profiles/:id, но не вправе выбрать любой :id. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.
Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.
\nФраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный user читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.
| Субъект | Действие | Объект | Ожидаемый ответ | Доверенный источник |
|---|---|---|---|---|
| u-1, t-1 | read | profile u-1, t-1 | allow, 200 | session + database |
| u-1, t-1 | read | profile u-2, t-1 | deny, 403 | session + database |
| u-1, t-1 | read | profile u-3, t-2 | deny, 403 | session + database |
| нет valid credentials | read | profile u-1, t-1 | deny, 401 | auth layer |
| u-1, t-1 | delete | profile u-1, t-1 | deny, 403 | route policy |
Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует WWW-Authenticate; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.
Нарисуйте путь данных до того, как писать условие. subjectId, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.
Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. ownerId из JSON описывает желание клиента, но не доказывает владение.
function authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (\n role === 'user' &&\n action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId\n ) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}\nЭто учебная policy-функция, а не готовое middleware. Она не проверяет подпись токена, срок сессии, CSRF, rate limit, кэш или журналирование. Её полезная граница уже видна: решение зависит от роли, действия, tenant и server-side владельца; неизвестная комбинация не проходит через случайную ветку allow.
\nHappy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как authorization-policy.test.js и выполните командой node authorization-policy.test.js. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.
const assert = require('node:assert/strict');\n\nfunction authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}\n\nconst cases = [\n ['owner reads own profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'allow', reason: 'same-tenant-owner' }],\n ['owner cannot read foreign profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-2', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['cross-tenant profile is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-2' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unknown role is denied',\n { role: 'guest', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unsupported action is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'delete',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n assert.deepEqual(authorize(input), expected, name);\n console.log('PASS', name);\n}\nЭтот тест теперь воспроизводим как unit-проверка, но не маскирует границу. Следующий слой должен вызвать реальный handler и передать ему две тестовые identities, два tenant-а и два объекта. Для read-only endpoint-а безопасный шаблон запроса выглядит так:
\ncurl -sS -i \\\n -H 'Authorization: Bearer <test-token-u-1>' \\\n 'https://test.example.test/api/profiles/u-2'\nНа тестовом окружении ожидайте выбранный контракт, здесь — 403 и отсутствие данных u-2 в теле. Второй запрос к u-1 должен дать 200, запрос без credentials — 401 с WWW-Authenticate. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.
Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не объект | u-1 запрашивает профиль u-2 напрямую | Добавить object-level deny и HTTP-тест |
| u-1 видит объект tenant t-2 | Tenant ограничили после общего чтения | Повторить запрос двумя tenant-ами | Включить tenant в выборку и policy |
| Policy вернула deny, HTTP дал 200 | Adapter или handler потерял решение | Сравнить policy result, статус и body | Сделать mapping явным и тестируемым |
| Чужой ответ появляется после прогрева кэша | Ключ не содержит permission context | Поменять субъекта после первого запроса | Разделить ключ или отключить общий cache |
| 403 раскрывает существование записи | Публичный ответ повторяет внутреннюю причину | Сравнить чужой и отсутствующий объект | Выбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным |
| UI-тест зелёный, endpoint уязвим | Проверяли только видимость кнопки | Вызвать маршрут без браузера | Оставить HTTP-проверку в CI |
Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.
\nЭта схема не проверяет подпись и срок жизни токена, MFA, CSRF, права на отдельные поля, загрузку файлов, SSRF, rate limit, гонки, репликацию базы, reverse proxy и корректность всех альтернативных маршрутов. Для массового запроса проверяйте каждый объект, а не только первый. Для администратора описывайте отдельные grants: роль сама по себе не означает право читать всё.
\nГотовность одного endpoint-а можно сформулировать строго: есть версия требования, доверенный источник subject/tenant/owner/action, серверная object-level проверка, unit-отказ и успешный HTTP-тест владельца. Прямой запрос к чужому и cross-tenant объекту возвращает закреплённый deny-ответ; запрос без credentials проходит auth-контракт; после кэша результат не меняется между субъектами. Это доказательство выбранной границы, а не сертификат безопасности приложения.
\n| Источник | Что подтверждает | Ограничение применимости |
|---|---|---|
| OWASP API1:2023 Broken Object Level Authorization | Изменение object ID может обойти контроль; endpoint, работающий с объектом по client input, должен проверять право на этот объект; нужны тесты authorization. | Это категория риска API и рекомендации, а не проверка конкретного приложения и не гарантия покрытия. |
| OWASP API5:2023 Broken Function Level Authorization | Function-level доступ нужно явно разрешать ролям, а неизвестные комбинации отклонять по умолчанию; это отдельная проблема от object-level доступа. | Материал не выбирает роли, tenant-модель или публичные HTTP-коды конкретного продукта. |
| OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References | Нужно картировать прямые ссылки на объекты, менять параметр и сравнивать доступ как минимум для двух пользователей с разными объектами. | Latest-версия руководства может обновляться; это методика тестирования, а не compliance standard и не разрешение тестировать чужие системы. |
| IETF RFC 9110, раздел 15.5 | Смысл 401, 403 и 404: credentials, отказ в выполнении и допустимое сокрытие существования ресурса; для 401 требуется WWW-Authenticate. | RFC описывает семантику HTTP, но не задаёт application policy, модель угроз или выбор между 403 и 404 для конкретного сервиса. |