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

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

\n

Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, curl или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.

\n

Логин определяет субъекта, а не разрешение

\n

Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль user ещё не означают право читать любой ресурс с той же ролью.

\n

Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: GET /profile — чтение, PATCH /profile — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть tenantId, состояние записи, принадлежность команде или требуемый уровень чувствительности.

\n

Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится deny. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.

\n

Где проходит серверная граница

\n

Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный subjectId, роль и, если применимо, tenantId. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.

\n

Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре id + tenantId, а не сначала получать любой объект по одному id. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.

\n

Клиентский ownerId не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.

\n

Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.

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

Самодостаточный учебный пример

\n

Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: Map заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.

\n
const profiles = new Map([\n  ['u-1', { ownerId: 'u-1', tenantId: 't-1', displayName: 'Ada' }],\n  ['u-2', { ownerId: 'u-2', tenantId: 't-1', displayName: 'Linus' }],\n  ['u-3', { ownerId: 'u-3', tenantId: 't-2', displayName: 'Grace' }],\n]);\n\nfunction authorizeProfileRead(session, requestedId) {\n  const profile = profiles.get(requestedId);\n  if (!profile) return { status: 404, reason: 'not-found' };\n  if (session.role !== 'user') return { status: 403, reason: 'role-denied' };\n  if (profile.tenantId !== session.tenantId) {\n    return { status: 403, reason: 'tenant-denied' };\n  }\n  if (profile.ownerId !== session.subjectId) {\n    return { status: 403, reason: 'owner-denied' };\n  }\n  return {\n    status: 200,\n    body: { id: requestedId, displayName: profile.displayName },\n  };\n}\n\nconst own = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-1');\nconst foreignObject = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-2');\nconst otherTenant = authorizeProfileRead(\n  { subjectId: 'u-1', tenantId: 't-1', role: 'user' }, 'u-3');\n\nconsole.log(own.status, foreignObject.status, otherTenant.status); // 200 403 403
\n

В примере URL-идентификатор передаётся только в requestedId. Субъект, роль и tenant приходят из session, а владелец читается из найденного профиля. Поэтому запрос от u-1 к u-2 не становится разрешённым после замены параметра. Изменение кода на реальный handler должно сохранить ту же последовательность и добавить проверку HTTP-метода, схемы входа и сериализации.

\n

Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.

\n

Проверяйте отрицательный путь

\n

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

\n

Отправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым id. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.

\n

Причину отказа можно сохранить в безопасном журнале коротким кодом вроде owner-denied. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.

\n

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

\n
  1. Выберите один endpoint и опишите его ресурс, метод, действие, субъект, tenant и изменяемые поля.
  2. Назовите доверенный источник каждого значения. Роль, владелец и область не должны приходить из пользовательского body.
  3. Загрузите объект в правильной области доступа; для tenant-системы включите tenant в условие репозитория.
  4. Определите явные allow-правила для каждой пары «ресурс + действие». Всё неизвестное оставьте deny.
  5. Разделите проверку credentials, объектную авторизацию, валидацию входа и сериализацию ответа.
  6. Добавьте allow-тест для своего объекта и deny-тесты для чужого объекта, другой области, запрещённого действия и неизвестной роли.
  7. Вызовите реальный HTTP-маршрут без UI и проверьте статус, тело, побочный эффект, кэш и заголовки.
  8. Зафиксируйте короткую причину решения и список непроверенных соседних маршрутов.
  9. Повторите отрицательные тесты после изменения middleware, репозитория, policy layer, схемы ответа или кэш-ключа.
\n

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

\n

Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.

\n

Фильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».

\n

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

\n

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

\n" }