{ "index": 14, "slug": "editorial-2027-08-mechanism-security-capstone", "title": "Аутентификация не даёт доступ: строим deny-by-default для объекта", "excerpt": "Валидная сессия не делает любой id разрешённым. Разбираем BOLA, доверенные поля, границу tenant-а и отрицательные HTTP-тесты от URL до ответа.", "contentHtml": "
Пользователь вошёл в систему и запросил /profile?id=u-2 вместо своего u-1. Токен действителен, поэтому сервер вернул чужую запись с кодом 200. Это горизонтальная эскалация прав: субъект прошёл аутентификацию, но получил объект, который ему не принадлежит. Цена ошибки — раскрытие персональных данных, изменение чужих записей и потеря границы между арендаторами (tenant-ами).
Проблема возникает там, где идентификатор из пути или строки параметров URL сразу передают в репозиторий. Сам факт, что пользователь знает URL и имеет рабочую сессию, не доказывает право на выбранный объект. Ниже — модель проверки, локальный воспроизводимый пример и набор отрицательных случаев. Код использует только фиктивные данные и не обращается к сети.
Аутентификация устанавливает субъекта: система проверяет учётные данные и связывает запрос с subjectId. Авторизация отвечает на другой вопрос: может ли этот субъект выполнить конкретное действие над конкретным объектом. Валидный токен даёт контекст личности, но не превращает каждый известный идентификатор в разрешённый.
В Broken Object Level Authorization (BOLA) доступ к функции уже предполагается: обычный пользователь вправе открыть endpoint профиля, но подменяет идентификатор и видит чужую запись. Это отличается от Broken Function Level Authorization (BFLA), когда тот же пользователь добирается до административной функции или меняет метод с GET на запрещённый DELETE. В реальном маршруте нужны обе проверки.
Роль помогает выбрать класс разрешений, но не описывает принадлежность каждой записи. Два субъекта могут иметь роль user и разные профили. Для решения нужны как минимум субъект, действие, объект и условия области доступа. Владелец, tenant и чувствительные свойства должны приходить из доверенного источника данных, а не из тела запроса.
Соберите контекст до вызова политики и явно отметьте источник каждого поля. Идентификатор из URL только выбирает кандидата. Он не назначает ему владельца и не меняет область хранения. Такой контракт легче просмотреть в коде и превратить в матрицу тестов.
| Поле | Доверенный источник | Что проверяем | Чего не делаем |
|---|---|---|---|
subjectId | Проверенная сессия или токен | Кто отправил запрос | Не читаем из URL и тела запроса |
role | Проверенные утверждения токена (claims) и политика | Какие функции доступны роли | Не принимаем роль от клиента |
action | Маршрут и HTTP-метод | Явное действие: read, write | Не разрешаем действие веткой «по умолчанию» |
resource | Серверная загрузка | Какой объект найден | Не доверяем сериализованному объекту клиента |
ownerId | Поле доменной записи | Совпадает ли владелец с субъектом | Не принимаем его из JSON-тела |
tenantId | Сессия и запись в хранилище | Одна ли это область доступа | Не разрешаем поиск в чужой области |
Политика должна возвращать отказ, если не совпала ни одна разрешённая комбинация. Неизвестная роль, действие, объект или область не должны попадать в «мягкую» ветку. На практике это означает список разрешений и последний результат deny, а не набор исключений, в котором легко забыть новый endpoint.
Политику вызывают на каждом бизнес-действии, а не только при отрисовке кнопки. Скрытый элемент интерфейса улучшает навигацию, но запрос можно отправить напрямую через HTTP-клиент. Middleware может подготовить субъект, handler — определить действие, репозиторий — вернуть запись, а слой политики — принять решение. Ни один из этих слоёв не должен подменять поля, которыми владеет другой.
Сохраним пример как access-check.cjs и запустим командой node access-check.cjs. Функция получает URL и уже проверенный контекст сессии. Заголовки здесь не используются: передача роли из клиентского заголовка была бы частью уязвимого примера, а не защитой. Map заменяет базу только для демонстрации.
const assert = require('node:assert/strict');\n\nconst profiles = new Map([\n ['u-1', { id: 'u-1', ownerId: 'u-1', tenantId: 't-1', displayName: 'Профиль u-1' }],\n ['u-2', { id: 'u-2', ownerId: 'u-2', tenantId: 't-1', displayName: 'Профиль u-2' }],\n ['u-3', { id: 'u-3', ownerId: 'u-3', tenantId: 't-2', displayName: 'Профиль u-3' }],\n]);\n\nfunction decide({ subjectId, role, tenantId, action, resource }) {\n if (!resource) return { status: 404, reason: 'not-found' };\n\n const canReadOwnProfile =\n role === 'user' &&\n action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId;\n\n if (canReadOwnProfile) return { status: 200, reason: 'owner' };\n return { status: 403, reason: 'default-deny' };\n}\n\nfunction getProfile({ requestUrl, subjectId, role, tenantId }) {\n const id = new URL(requestUrl, 'http://local').searchParams.get('id');\n const stored = profiles.get(id);\n const resource = stored && { kind: 'profile', ...stored };\n const decision = decide({\n subjectId,\n role,\n tenantId,\n action: 'read',\n resource,\n });\n\n const body = decision.status === 200\n ? { id: stored.id, displayName: stored.displayName }\n : { error: decision.status === 404 ? 'not-found' : 'forbidden' };\n return { status: decision.status, body };\n}\n\nconst cases = [\n {\n name: 'own profile',\n input: { requestUrl: 'http://local/profile?id=u-1', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 200, body: { id: 'u-1', displayName: 'Профиль u-1' } },\n },\n {\n name: 'foreign owner',\n input: { requestUrl: 'http://local/profile?id=u-2', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'foreign tenant',\n input: { requestUrl: 'http://local/profile?id=u-3', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'unknown role',\n input: { requestUrl: 'http://local/profile?id=u-1', subjectId: 'u-1', role: 'guest', tenantId: 't-1' },\n expected: { status: 403, body: { error: 'forbidden' } },\n },\n {\n name: 'missing object',\n input: { requestUrl: 'http://local/profile?id=u-9', subjectId: 'u-1', role: 'user', tenantId: 't-1' },\n expected: { status: 404, body: { error: 'not-found' } },\n },\n];\n\nfor (const item of cases) {\n assert.deepEqual(getProfile(item.input), item.expected, item.name);\n console.log(item.name + ': ' + item.expected.status);\n}Ожидаемый вывод содержит статусы 200, 403, 403, 403 и 404. В разрешённом ответе наружу попадают только id и отображаемое имя. ownerId, tenantId и внутренний reason не становятся частью API-ответа.
Важна не только строка с условием. subjectId, роль и tenant в примере обозначают уже проверенный контекст. Если реальный обработчик заполнит их из пользовательского JSON или недоверенного заголовка, локальная функция перестанет доказывать нужное свойство. На границе HTTP нужно отдельно проверить подпись и срок жизни токена, а затем передать результат проверки в policy.
Для tenant-системы область лучше включать в запрос к хранилищу: SELECT id, owner_id, tenant_id FROM profiles WHERE id = :id AND tenant_id = :tenant. Так репозиторий не возвращает запись из чужой области обычному обработчику. Это не отменяет policy: роль, действие и владелец всё равно требуют проверки. Но граница данных появляется раньше сериализации, журналирования и работы с кэшем.
Проверка владельца после широкого поиска может выглядеть безопасно, если ответ всегда отбрасывается. Она всё равно усложняет защиту: объект уже попал в память процесса, ошибочный лог или промежуточный кэш. В запросах на изменение дополнительно разрешайте только перечисленные свойства. Поле ownerId, tenant и признаки статуса не должны обновляться массовой привязкой тела запроса.
Код ответа не заменяет policy, но помогает не смешивать границы. Отсутствующие или недействительные учётные данные относятся к 401. Сервер понял запрос, но не разрешил действие над известным объектом, — это кандидат на 403. Когда публикация самого факта существования записи опасна, сервис может вернуть 404 и для запрещённого объекта. Выбор должен быть единым для endpoint-а и модели угроз, а не случайной реакцией разных обработчиков.
401: слой аутентификации не получил действительных учётных данных; до объектной политики запрос обычно не дошёл.403: субъект распознан, но комбинация роли, действия, объекта или области не разрешена.404: запись отсутствует либо сервис не раскрывает, что запрещённый ресурс существует.Внешнее сообщение можно сделать одинаковым для нескольких отказов, а внутренний журнал — полезным для расследования. В него не должны попадать полный токен, пароль, секретные query-параметры и лишние персональные поля. Логируйте короткий класс причины, идентификатор операции и минимальный контекст, который разрешено хранить.
Положительный тест на свой профиль показывает только одну разрешённую строку. Он не проверяет, что граница закрыта для соседа, другой области или неизвестной роли. Матрица должна включать по меньшей мере такие наблюдаемые случаи:
| Вход | Ожидаемое решение | Что подтверждает тест |
|---|---|---|
user u-1 → profile u-1, read | allow / 200 | Легитимный сценарий не сломан |
user u-1 → profile u-2, read | deny, внешний 403 или 404 | Подмена id не даёт чужой объект |
user u-1 → profile u-3, другой tenant | deny, без данных записи | Область участвует в решении |
guest u-1 → profile u-1 | deny | Неизвестная роль не получает доступ |
user u-1 → audit или запрещённый метод | deny до бизнес-операции | Роль и действие не подменяются URL |
Запускайте такие проверки через настоящий маршрут и через прямой HTTP-клиент, без кликов в браузере. Сравнивайте код, тело и набор полей ответа для разрешённого и запрещённого случаев. Отдельно проверьте прокси и кэш: персональный ответ не должен попасть под общим ключом к следующему субъекту.
deny.401/403/404 и не возвращайте внутреннюю причину отказа без необходимости.Учебная функция не проверяет подпись JWT, срок жизни сессии, CSRF, атомарность транзакции, согласованность реплик, правила администратора, правила для отдельных полей и реальный кэш. Случайные или длинные идентификаторы могут затруднить перебор, но не заменяют проверку разрешений. Ни одна библиотека не знает автоматически, кому принадлежит объект в вашей предметной области.
Для одного endpoint-а работа готова, когда интеграционный тест через реальный маршрут разрешает свой объект, отказывает чужому и неизвестной роли, проверяет другую область и сравнивает тело ответа. У запретной ветки нет приватных полей, а решение не зависит от видимости UI-кнопки. Следующий шаг — взять один endpoint в окружении, похожем на production, записать матрицу «субъект × действие × объект» и сохранить эти отрицательные случаи как регрессионные тесты.
401, 403 и 404, включая возможность скрывать существование запрещённого ресурса через 404. Граница: RFC не выбирает policy и модель угроз продукта.