{ "index": 14, "slug": "editorial-2027-08-mechanism-security-capstone", "title": "Проверка логина не защищает объект: строим deny-by-default", "excerpt": "Пользователь может быть правильно аутентифицирован и всё равно не иметь права читать выбранную запись. Разбираем объектную авторизацию, проверку владельца и отрицательный путь от URL до ответа 403.", "contentHtml": "

Пользователь входит в систему, открывает /profile?id=u-1, меняет один символ и получает профиль u-2. Токен остаётся действительным. Сервер проверяет его наличие и отдаёт найденную запись. Симптом выглядит как обычный доступ к странице, но это горизонтальная эскалация прав.

Цена ошибки — утечка персональных данных, изменение чужих объектов и потеря границы между tenant-ами. Чем больше endpoint-ов строится по схеме «взяли id из URL, нашли запись, вернули ответ», тем больше таких точек появляется. Скрытая кнопка в интерфейсе не помогает: запрос можно повторить вручную.

Тезис: логин устанавливает субъекта, но не разрешение

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

Надёжное решение принимает четыре значения: субъект, действие, объект и контекст политики. Субъект приходит из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода. Объект загружается сервером. Владелец, tenant и чувствительность объекта берутся из доверенных данных, а не из тела запроса. Неизвестная комбинация получает deny.

Механизм объектной проверки

Сначала middleware или слой сессии устанавливает subjectId и роль. Затем handler разбирает путь и получает идентификатор ресурса. Репозиторий возвращает объект вместе с его владельцем и tenant-ом. Policy layer сравнивает эти поля с субъектом и разрешает только явно описанные действия. UI может скрыть недоступную кнопку, но решение всё равно принимает сервер.

Правило владельца нельзя заменить проверкой роли. Два пользователя могут иметь одну роль user, но видеть разные профили. Роль говорит о классе полномочий. Владелец говорит о конкретном объекте. Для администратора нужен отдельный allow-список: «читать аудит» не равно «читать любую персональную запись», а «администратор» не должно означать «разрешено всё».

Нельзя принять ownerId из JSON и использовать его как доказательство владения. Клиент сообщает, какой объект он хочет выбрать. Сервер сам читает владельца из базы или доменного сервиса. Для multi-tenant системы запрос к хранилищу должен сразу включать tenant boundary. Если сначала получить запись без ограничения области, последующая проверка уже может оказаться слишком поздней.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Замена id в URL показывает чужой профильПроверили сессию, но не владельца объектаОтправить запрос для своего и соседнего идентификатораСравнивать subjectId с владельцем записи; чужой объект отклонять
Пользователь читает audit endpointРоль проверяют слишком широкоВызвать endpoint с ролью user напрямую, без UIОписать ресурс и действие в отдельном allow-правиле
Неизвестная роль получает 200Ветка по умолчанию пропускает запросУдалить или подменить claim и повторить вызовСделать результатом по умолчанию deny
Чужой ответ появляется после кэшированияКлюч кэша не содержит субъекта или областиПовторить запрос разными субъектами и сравнить телоРазделить кэш по permission context либо не кэшировать ответ
403 раскрывает существование записиВнешний ответ повторяет внутреннюю причинуСравнить ответ для отсутствующего и чужого объектаВыбрать 404 или 403 по модели угроз, причину логировать безопасно
Матрица авторизации связывает субъекта, роль, действие, объект и владельца с решением allow или deny
Решение строится на серверных атрибутах объекта. Изменение идентификатора в запросе не меняет его владельца и tenant.

Учебный endpoint

Ниже — маленький пример на Node.js. Профили хранятся в Map, а субъект и роль передаются заголовками только для учебного сценария. Реальная система должна получать их из проверенной сессии, JWT или другого принятого механизма. Пример не подключается к production и не доказывает безопасность конкретного приложения.

const profiles = new Map([['u-1', { owner: 'u-1', tenant: 't-1' }], ['u-2', { owner: 'u-2', tenant: 't-1' }]]); function decide({ role, subjectId, tenant, resource, action }) { if (role === 'admin' && resource.kind === 'audit' && action === 'read') return { status: 200, reason: 'admin-audit' }; if (role === 'user' && resource.kind === 'profile' && action === 'read' && resource.tenant === tenant && resource.owner === subjectId) return { status: 200, reason: 'owner' }; return { status: 403, reason: 'default-deny' }; } const id = new URL(request.url, 'http://local').searchParams.get('id'); const profile = profiles.get(id); const resource = profile && { kind: 'profile', ...profile }; const result = resource ? decide({ role, subjectId, tenant, resource, action: 'read' }) : { status: 404, reason: 'not-found' };

Условие для профиля проверяет роль, действие, tenant и владельца. Запрос от u-1 к u-1 получает 200. Тот же субъект к u-2 получает 403. Если tenant отличается, результат также deny. Идентификатор из URL только выбирает запись; он не назначает ей владельца.

Проверка существования объекта требует отдельного решения. В примере отсутствующий профиль даёт 404, а найденный чужой — 403. В некоторых системах оба случая наружу превращают в 404, чтобы не раскрывать наличие записи. Это не универсальное правило. Выбор зависит от модели угроз. Внутри сохраняйте короткий класс причины и не пишите в журнал полный токен или секретные параметры.

Отрицательный путь важнее happy path

Разрешённый запрос показывает, что легитимный сценарий работает. Он не показывает, что граница закрыта. Минимальный набор должен включать чужой объект, запрещённое действие, неизвестную роль, другой tenant, отсутствующий ресурс и повторный вызов через прямой HTTP-клиент. Для mutation добавьте проверку метода и защиту от повторной операции. Для чтения проверьте кэш и сериализацию ответа.

Проверяйте policy без интерфейса. Если тест кликает только по видимой кнопке, он не проверяет handler. Отправьте запрос с изменённым id, вручную задайте роль и удалите обязательный claim. Эти входы учебные и не должны содержать реальные идентификаторы или секреты. Их смысл — показать отрицательную ветку, а не воспроизвести доступ к настоящим данным.

Порядок внедрения

  1. Составьте карту одного endpoint-а: субъект, HTTP-метод, действие, объект, tenant и внешний ответ.
  2. Определите доверенный источник каждого поля. Не берите роль, владельца и tenant из пользовательского body.
  3. Загрузите объект в правильной области доступа. Для tenant-системы включите tenant в запрос к хранилищу.
  4. Запишите явные allow-правила для ресурса и действия. Оставьте deny результатом для неизвестной комбинации.
  5. Добавьте тесты своего объекта, чужого объекта, запрещённого действия, неизвестной роли и другой области.
  6. Вызовите handler напрямую, без UI, и проверьте код, тело, кэш и отсутствие лишних данных в ответе.
  7. Настройте безопасное журналирование: причина должна помогать расследованию, но не содержать токены, пароли и полный URL с секретами.
  8. Повторите отрицательные тесты после изменения middleware, репозитория, политики и ключа кэша.

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

Учебный код использует заголовки вместо настоящей аутентификации и Map вместо базы. Он не проверяет срок жизни токена, подпись JWT, CSRF, race condition, права на поля, согласованность реплик и поведение прокси. Он также не решает, как кэшировать персональный ответ. Эти вопросы требуют отдельных контрактов и тестов.

Проверка готова для одного endpoint-а, если видны источник subjectId, правило области, серверный способ получения владельца, явное действие и default deny. Интеграционный тест должен показать 200 для разрешённого объекта, отказ для чужого объекта и отказ для неизвестной роли через реальный HTTP-маршрут. Если проходит только unit-тест policy или только проверка UI, работа не готова: граница между запросом, хранилищем и ответом ещё не доказана.

Проверяемые источники

" }