8 lines
20 KiB
JSON
8 lines
20 KiB
JSON
{
|
||
"index": 14,
|
||
"slug": "editorial-2027-08-mechanism-security-capstone",
|
||
"title": "Проверка логина не защищает объект: строим deny-by-default",
|
||
"excerpt": "Валидная сессия отвечает только на вопрос «кто отправил запрос». Разбираем объектную авторизацию, границу tenant-а и отрицательный путь от URL до ответа 403 или 404.",
|
||
"contentHtml": "<p>Пользователь входит в систему, открывает <code>/profile?id=u-1</code>, меняет один символ и получает профиль <code>u-2</code>. Токен остаётся действительным, поэтому проверка логина проходит. Ошибка возникает дальше: сервер не проверяет, имеет ли этот субъект право читать выбранный объект. Это горизонтальная эскалация прав.</p>\n<p>Цена ошибки измеряется не только одним лишним экраном. В профиле могут оказаться персональные данные, в заказе — адрес и сумма, а в mutation — возможность изменить чужую запись. Скрытая кнопка не закрывает маршрут: запрос повторяется через DevTools, <code>curl</code> или автоматический тест. Поэтому границу нужно проверять там, где сервер загружает объект и формирует ответ.</p>\n<h2>Логин определяет субъекта, а не разрешение</h2>\n<p>Аутентификация устанавливает, кто отправил запрос. Авторизация решает, можно ли этому субъекту выполнить конкретное действие над конкретным объектом. Эти проверки связаны, но не заменяют друг друга. Наличие cookie, валидный JWT и роль <code>user</code> ещё не означают право читать любой ресурс с той же ролью.</p>\n<p>Для одного решения зафиксируйте четыре входа: субъект, действие, объект и контекст. Субъект берётся из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода: <code>GET /profile</code> — чтение, <code>PATCH /profile</code> — изменение. Объект выбирается по идентификатору запроса, но его владелец и область берутся из хранилища. Контекстом могут быть <code>tenantId</code>, состояние записи, принадлежность команде или требуемый уровень чувствительности.</p>\n<p>Политика должна явно описать разрешённые комбинации. Если роль, действие, тип ресурса или область неизвестны, результатом становится <code>deny</code>. Это и есть deny-by-default: новая ветка не получает доступ только потому, что разработчик забыл добавить условие запрета.</p>\n<h2>Где проходит серверная граница</h2>\n<p>Надёжный маршрут начинается с источников доверия. Middleware проверяет сессию и передаёт дальше нормализованный <code>subjectId</code>, роль и, если применимо, <code>tenantId</code>. Handler получает идентификатор объекта из URL. Репозиторий читает запись с ограничением области. Policy layer сопоставляет серверные атрибуты объекта с субъектом и действием. Только после этого сериализатор строит ответ.</p>\n<p>Для tenant-системы область нужно включить уже в запрос к хранилищу. Например, выборка должна искать запись по паре <code>id + tenantId</code>, а не сначала получать любой объект по одному <code>id</code>. Это сокращает риск ошибочного использования чужой записи в следующем слое. Но фильтр репозитория не отменяет policy: владелец, роль и разрешённое действие всё равно должны быть частью проверяемого решения.</p>\n<p>Клиентский <code>ownerId</code> не является доказательством владения. Клиент может сообщить, какой объект хочет выбрать, но не может назначить себе владельца, tenant или роль. То же правило относится к скрытым полям формы, заголовкам, query-параметрам и данным, которые приходят от другого сервиса без проверки происхождения.</p>\n<p>Роль тоже нельзя превращать в универсальный пропуск. Администратору может быть разрешено читать аудит, но не персональные поля; оператору — менять статус заявки, но не владельца. Чем шире правило «admin может всё», тем труднее увидеть, какой объект и какое действие оно открывает. Разделяйте права на ресурс и операцию, а исключения записывайте рядом с их основанием.</p>\n<div class='table-scroll'><table><caption>Симптом → причина → проверка → действие</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Причина</th><th scope='col'>Проверка</th><th scope='col'>Действие</th></tr></thead><tbody><tr><td>Замена <code>id</code> в URL показывает чужой профиль</td><td>Сессию проверили, владельца объекта — нет</td><td>Запросить свой и соседний идентификатор одной сессией</td><td>Сверять <code>subjectId</code> с владельцем и областью объекта</td></tr><tr><td>Роль <code>user</code> читает audit endpoint</td><td>Проверка роли не связана с ресурсом и действием</td><td>Вызвать маршрут напрямую, без интерфейса</td><td>Добавить отдельное allow-правило для <code>audit:read</code></td></tr><tr><td>Неизвестная роль получает 200</td><td>Ветка по умолчанию пропускает запрос</td><td>Удалить claim и повторить вызов</td><td>Вернуть отказ для любой неописанной комбинации</td></tr><tr><td>Чужой ответ появляется после кэширования</td><td>Ключ кэша не учитывает область или permission context</td><td>Сравнить ответы для двух субъектов</td><td>Разделить кэш или отключить его для персонального ответа</td></tr><tr><td>Ответ раскрывает наличие чужой записи</td><td>Внешний статус и текст выбраны без модели угроз</td><td>Сравнить отсутствующий и запрещённый объект</td><td>Выбрать 403 или маскирующий 404 и не раскрывать причину</td></tr></tbody></table></div>\n<figure><img src='/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg' alt='Матрица объектной авторизации связывает субъект, роль, действие, tenant и владельца записи с решением allow или deny' loading='lazy' /><figcaption>Идентификатор из URL выбирает объект, но не меняет его владельца и tenant. Решение принимается по серверным атрибутам.</figcaption></figure>\n<h2>Самодостаточный учебный пример</h2>\n<p>Следующий фрагмент можно выполнить в Node.js без базы данных. Он моделирует только авторизацию чтения профиля: <code>Map</code> заменяет репозиторий, а объект сессии — результат настоящей проверки учётных данных. В production нельзя принимать сессию из тела запроса или доверять заголовку, который клиент может подменить.</p>\n<pre><code>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</code></pre>\n<p>В примере URL-идентификатор передаётся только в <code>requestedId</code>. Субъект, роль и tenant приходят из <code>session</code>, а владелец читается из найденного профиля. Поэтому запрос от <code>u-1</code> к <code>u-2</code> не становится разрешённым после замены параметра. Изменение кода на реальный handler должно сохранить ту же последовательность и добавить проверку HTTP-метода, схемы входа и сериализации.</p>\n<p>Код намеренно возвращает 403 для найденного, но чужого объекта и 404 для отсутствующего. HTTP Semantics допускает 404, когда сервер не хочет раскрывать существование запрещённого ресурса. Это не автоматическое требование для каждого API: решение зависит от того, нужна ли клиенту разница между «нет записи» и «нет доступа», и что может узнать атакующий по ответу.</p>\n<h2>Проверяйте отрицательный путь</h2>\n<p>Успешное чтение собственного профиля доказывает только один allow-сценарий. Оно не показывает, что граница закрыта. Минимальный набор проверок должен содержать чужой объект в том же tenant-е, объект другого tenant-а, запрещённое действие, неизвестную роль, отсутствующий объект и запрос без обязательных учётных данных. Для mutation дополнительно проверяйте, что состояние не изменилось после отказа.</p>\n<p>Отправляйте эти запросы напрямую к HTTP-маршруту. UI-тест, который видит только доступную кнопку, не проверяет handler с изменённым <code>id</code>. В ответе проверяйте статус, тело, заголовки и отсутствие лишних полей. Если включён кэш, выполняйте последовательность «субъект A → тот же URL субъект B» и сравнивайте результат. В прокси и CDN отдельно смотрите, не стал ли персональный ответ общим.</p>\n<p>Причину отказа можно сохранить в безопасном журнале коротким кодом вроде <code>owner-denied</code>. Не записывайте токены, пароли, полное тело запроса и URL, в котором секрет оказался в query-параметре. Лог должен помогать отличить ошибку политики от отсутствующего объекта, но не становиться вторым каналом утечки.</p>\n<h2>Порядок внедрения</h2>\n<ol><li>Выберите один endpoint и опишите его ресурс, метод, действие, субъект, tenant и изменяемые поля.</li><li>Назовите доверенный источник каждого значения. Роль, владелец и область не должны приходить из пользовательского body.</li><li>Загрузите объект в правильной области доступа; для tenant-системы включите tenant в условие репозитория.</li><li>Определите явные allow-правила для каждой пары «ресурс + действие». Всё неизвестное оставьте deny.</li><li>Разделите проверку credentials, объектную авторизацию, валидацию входа и сериализацию ответа.</li><li>Добавьте allow-тест для своего объекта и deny-тесты для чужого объекта, другой области, запрещённого действия и неизвестной роли.</li><li>Вызовите реальный HTTP-маршрут без UI и проверьте статус, тело, побочный эффект, кэш и заголовки.</li><li>Зафиксируйте короткую причину решения и список непроверенных соседних маршрутов.</li><li>Повторите отрицательные тесты после изменения middleware, репозитория, policy layer, схемы ответа или кэш-ключа.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Учебный код не проверяет подпись и срок жизни JWT, отзыв сессии, CSRF, права на отдельные поля, race condition, согласованность реплик, GraphQL resolver, WebSocket или фонового потребителя очереди. У каждого канала свой handler и свой объектный контекст. Проверка одного GET не даёт права объявить защищёнными PATCH, экспорт, поиск и административные маршруты.</p>\n<p>Фильтр по tenant-у не решает все задачи мультиарендности: остаются ошибки конфигурации, смешение кэшей, фоновые задачи без субъекта и служебные аккаунты. Проверка владельца не решает делегирование, совместный доступ и временные полномочия. Для них нужны отдельные правила и отрицательные тесты, а не расширение условия до «если роль admin».</p>\n<p>Endpoint можно считать проверенным только для заявленного контракта, если видны источник субъекта, правило области, серверный способ получения владельца, действие, внешний статус и доказательство отказа. Интеграционный тест должен дать 200 своему объекту, отказать чужому объекту и неизвестной роли через реальный маршрут. Если зелёным остаётся только unit-тест policy или только проверка UI, связь между запросом, хранилищем и ответом ещё не доказана.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/' target='_blank' rel='noopener noreferrer'>OWASP API Security Top 10: API1:2023 Broken Object Level Authorization</a> — официальное описание проверки права пользователя на каждый объект, к которому обращается API. Материал объясняет класс риска, но не выбирает правила владельца и tenant-а для конкретного продукта.</li><li><a href='https://owasp.org/www-project-application-security-verification-standard/' target='_blank' rel='noopener noreferrer'>OWASP Application Security Verification Standard 5.0.0</a> — официальный стандарт для проверки технических security controls веб-приложения; страница отдельно предупреждает, что идентификаторы требований зависят от версии. Стандарт задаёт базу для проверки, но не является результатом аудита данного endpoint-а.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html#name-403-forbidden' target='_blank' rel='noopener noreferrer'>RFC 9110, разделы 15.5.4–15.5.5</a> — нормативная семантика 403 Forbidden и возможность вернуть 404, чтобы не раскрывать существование запрещённого ресурса. RFC не определяет бизнес-политику доступа и не требует одного статуса для всех приложений.</li><li><a href='https://csrc.nist.gov/glossary/term/least_privilege' target='_blank' rel='noopener noreferrer'>NIST CSRC Glossary: least privilege</a> — официальное определение принципа минимально необходимых полномочий для пользователя или процесса. Оно поддерживает ограниченные allow-правила, но не заменяет threat model и тесты маршрута.</li></ul>"
|
||
}
|