{ "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. Токен остаётся действительным, поэтому проверка логина проходит. Ошибка возникает дальше: сервер не проверяет, имеет ли этот субъект право читать выбранный объект. Это горизонтальная эскалация прав.
Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, curl или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.
Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль user ещё не означают право читать любой ресурс с той же ролью.
Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: GET /profile — чтение, PATCH /profile — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть tenantId, состояние записи, принадлежность команде или требуемый уровень чувствительности.
Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится deny. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.
Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный subjectId, роль и, если применимо, tenantId. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.
Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре id + tenantId, а не сначала получать любой объект по одному id. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.
Клиентский ownerId не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.
Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
Замена id в URL показывает чужой профиль | Сессию проверили, владельца объекта — нет | Запросить свой и соседний идентификатор одной сессией | Сверять subjectId с владельцем и областью объекта |
Роль user читает audit endpoint | Проверка роли не связана с ресурсом и действием | Вызвать маршрут напрямую, без интерфейса | Добавить отдельное allow-правило для audit:read |
| Неизвестная роль получает 200 | Ветка по умолчанию пропускает запрос | Удалить claim и повторить вызов | Вернуть отказ для любой неописанной комбинации |
| Чужой ответ появляется после кэширования | Ключ кэша не учитывает область или permission context | Сравнить ответы для двух субъектов | Разделить кэш или отключить его для персонального ответа |
| Ответ раскрывает наличие чужой записи | Внешний статус и текст выбраны без модели угроз | Сравнить отсутствующий и запрещённый объект | Выбрать 403 или маскирующий 404 и не раскрывать причину |
Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: Map заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.
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-метода, схемы входа и сериализации.
Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.
\nУспешное чтение собственного профиля доказывает только один allow-сценарий. Оно не показывает, что граница закрыта. Минимальный набор проверок должен содержать чужой объект в том же tenant-е, объект другого tenant-а, запрещённое действие, неизвестную роль, отсутствующий объект и запрос без обязательных учётных данных. Для mutation дополнительно проверяйте, что состояние не изменилось после отказа.
\nОтправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым id. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.
Причину отказа можно сохранить в безопасном журнале коротким кодом вроде owner-denied. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.
Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.
\nФильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».
\nEndpoint можно считать проверенным только для заявленного контракта, если видны источник субъекта, правило области, серверный способ получения владельца, действие, внешний статус и доказательство отказа. Интеграционный тест должен дать 200 своему объекту, отказать чужому объекту и неизвестной роли через реальный маршрут. Если зелёным остаётся только unit-тест policy или только проверка UI, связь между запросом, хранилищем и ответом ещё не доказана.
\n