{ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", "excerpt": "Как связать security-требование, субъекта, объект и действие, а затем доказать на HTTP-границе, что чужой идентификатор не даёт доступ.", "contentHtml": "

Пользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.

\n

Разберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.

\n

Почему успешный вход ничего не доказывает

\n

Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает id из URL, а затем делает поиск только по этому id, он может вернуть запись, которая субъекту не принадлежит.

\n

Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать GET /profiles/:id, но не вправе выбрать любой :id. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.

\n

Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.

\n

Требование превращается в проверяемую строку

\n

Фраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный user читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.

\n
Матрица входов для endpoint-а профиля
СубъектДействиеОбъектОжидаемый ответДоверенный источник
u-1, t-1readprofile u-1, t-1allow, 200session + database
u-1, t-1readprofile u-2, t-1deny, 403session + database
u-1, t-1readprofile u-3, t-2deny, 403session + database
нет valid credentialsreadprofile u-1, t-1deny, 401auth layer
u-1, t-1deleteprofile u-1, t-1deny, 403route policy
\n

Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует WWW-Authenticate; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.

\n

Где должен находиться контроль

\n

Нарисуйте путь данных до того, как писать условие. subjectId, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.

\n

Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. ownerId из JSON описывает желание клиента, но не доказывает владение.

\n
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.

\n
Цикл доказательства авторизации: требование связывается с субъектом и объектом, затем проходит отрицательный HTTP-тест и возвращается в разбор при расхождении.
Проверка замыкается только после сравнения ожидаемого и фактического HTTP-ответа. Unit-решение policy — промежуточное evidence, а не доказательство маршрута.
\n

Отрицательный тест должен быть исполняемым

\n

Happy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как authorization-policy.test.js и выполните командой node authorization-policy.test.js. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.

\n
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-а безопасный шаблон запроса выглядит так:

\n
curl -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. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.

\n

Проверяем не только policy

\n

Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.

\n
Симптом → причина → проверка → действие
СимптомВероятная причинаПроверкаДействие
Чужой профиль отвечает 200Проверили токен, но не объектu-1 запрашивает профиль u-2 напрямуюДобавить object-level deny и HTTP-тест
u-1 видит объект tenant t-2Tenant ограничили после общего чтенияПовторить запрос двумя tenant-амиВключить tenant в выборку и policy
Policy вернула deny, HTTP дал 200Adapter или handler потерял решениеСравнить policy result, статус и bodyСделать mapping явным и тестируемым
Чужой ответ появляется после прогрева кэшаКлюч не содержит permission contextПоменять субъекта после первого запросаРазделить ключ или отключить общий cache
403 раскрывает существование записиПубличный ответ повторяет внутреннюю причинуСравнить чужой и отсутствующий объектВыбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным
UI-тест зелёный, endpoint уязвимПроверяли только видимость кнопкиВызвать маршрут без браузераОставить HTTP-проверку в CI
\n

Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.

\n

Порядок проверки одного endpoint-а

\n
  1. Запишите маршрут, HTTP-метод, действие и тип объекта. Выберите тестовые identities, которыми разрешено пользоваться.
  2. Составьте матрицу: свой объект, чужой объект того же tenant-а, объект другого tenant-а, отсутствующая аутентификация и недопустимое действие.
  3. Назначьте источник каждого поля. Subject и роль приходят из auth context, owner и tenant — из серверного объекта, action — из маршрута.
  4. Проверьте запрос к хранилищу. Убедитесь, что tenant boundary не добавляется после выдачи данных и что объект не строится из client body.
  5. Напишите unit-тест policy с явным default deny. Отдельно проверьте реальный handler и сериализацию.
  6. Повторите все строки через HTTP без UI. Сохраните статус, безопасное тело, заголовки и идентификатор случая.
  7. Прогрейте кэш и повторите пары разными субъектами. Для mutation добавьте проверку отсутствия изменения после deny.
  8. Зафиксируйте расхождение как дефект policy, data access, mapping, cache или контракта. Не удаляйте отрицательный тест ради зелёного pipeline.
\n

Ограничения и критерий готовности

\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 AuthorizationFunction-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 для конкретного сервиса.
" }