8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 14,
|
||
"slug": "editorial-2027-08-mechanism-security-capstone",
|
||
"title": "Аутентификация не даёт доступ: строим deny-by-default для объекта",
|
||
"excerpt": "Валидная сессия не делает любой id разрешённым. Разбираем BOLA, доверенные поля, границу tenant-а и отрицательные HTTP-тесты от URL до ответа.",
|
||
"contentHtml": "<p>Пользователь вошёл в систему и запросил <code>/profile?id=u-2</code> вместо своего <code>u-1</code>. Токен действителен, поэтому сервер вернул чужую запись с кодом <code>200</code>. Это горизонтальная эскалация прав: субъект прошёл аутентификацию, но получил объект, который ему не принадлежит. Цена ошибки — раскрытие персональных данных, изменение чужих записей и потеря границы между арендаторами (tenant-ами).</p><p>Проблема возникает там, где идентификатор из пути или строки параметров URL сразу передают в репозиторий. Сам факт, что пользователь знает URL и имеет рабочую сессию, не доказывает право на выбранный объект. Ниже — модель проверки, локальный воспроизводимый пример и набор отрицательных случаев. Код использует только фиктивные данные и не обращается к сети.</p><h2>Аутентификация отвечает не на тот вопрос</h2><p>Аутентификация устанавливает субъекта: система проверяет учётные данные и связывает запрос с <code>subjectId</code>. Авторизация отвечает на другой вопрос: может ли этот субъект выполнить конкретное действие над конкретным объектом. Валидный токен даёт контекст личности, но не превращает каждый известный идентификатор в разрешённый.</p><p>В Broken Object Level Authorization (BOLA) доступ к функции уже предполагается: обычный пользователь вправе открыть endpoint профиля, но подменяет идентификатор и видит чужую запись. Это отличается от Broken Function Level Authorization (BFLA), когда тот же пользователь добирается до административной функции или меняет метод с <code>GET</code> на запрещённый <code>DELETE</code>. В реальном маршруте нужны обе проверки.</p><p>Роль помогает выбрать класс разрешений, но не описывает принадлежность каждой записи. Два субъекта могут иметь роль <code>user</code> и разные профили. Для решения нужны как минимум субъект, действие, объект и условия области доступа. Владелец, tenant и чувствительные свойства должны приходить из доверенного источника данных, а не из тела запроса.</p><h2>Контекст решения должен быть доверенным</h2><p>Соберите контекст до вызова политики и явно отметьте источник каждого поля. Идентификатор из URL только выбирает кандидата. Он не назначает ему владельца и не меняет область хранения. Такой контракт легче просмотреть в коде и превратить в матрицу тестов.</p><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>subjectId</code></td><td>Проверенная сессия или токен</td><td>Кто отправил запрос</td><td>Не читаем из URL и тела запроса</td></tr><tr><td><code>role</code></td><td>Проверенные утверждения токена (claims) и политика</td><td>Какие функции доступны роли</td><td>Не принимаем роль от клиента</td></tr><tr><td><code>action</code></td><td>Маршрут и HTTP-метод</td><td>Явное действие: <code>read</code>, <code>write</code></td><td>Не разрешаем действие веткой «по умолчанию»</td></tr><tr><td><code>resource</code></td><td>Серверная загрузка</td><td>Какой объект найден</td><td>Не доверяем сериализованному объекту клиента</td></tr><tr><td><code>ownerId</code></td><td>Поле доменной записи</td><td>Совпадает ли владелец с субъектом</td><td>Не принимаем его из JSON-тела</td></tr><tr><td><code>tenantId</code></td><td>Сессия и запись в хранилище</td><td>Одна ли это область доступа</td><td>Не разрешаем поиск в чужой области</td></tr></tbody></table></div><figure><img src=\"/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg\" alt=\"Матрица авторизации связывает субъект, роль, действие, объект и владельца с решением allow или deny\" loading=\"lazy\" /><figcaption>Решение опирается на серверные атрибуты. Подмена идентификатора в запросе не меняет владельца записи.</figcaption></figure><h2>Deny-by-default — явное решение для неизвестного</h2><p>Политика должна возвращать отказ, если не совпала ни одна разрешённая комбинация. Неизвестная роль, действие, объект или область не должны попадать в «мягкую» ветку. На практике это означает список разрешений и последний результат <code>deny</code>, а не набор исключений, в котором легко забыть новый endpoint.</p><p>Политику вызывают на каждом бизнес-действии, а не только при отрисовке кнопки. Скрытый элемент интерфейса улучшает навигацию, но запрос можно отправить напрямую через HTTP-клиент. Middleware может подготовить субъект, handler — определить действие, репозиторий — вернуть запись, а слой политики — принять решение. Ни один из этих слоёв не должен подменять поля, которыми владеет другой.</p><h2>Воспроизводимая проверка URL и объекта</h2><p>Сохраним пример как <code>access-check.cjs</code> и запустим командой <code>node access-check.cjs</code>. Функция получает URL и уже проверенный контекст сессии. Заголовки здесь не используются: передача роли из клиентского заголовка была бы частью уязвимого примера, а не защитой. <code>Map</code> заменяет базу только для демонстрации.</p><pre><code>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}</code></pre><p>Ожидаемый вывод содержит статусы <code>200</code>, <code>403</code>, <code>403</code>, <code>403</code> и <code>404</code>. В разрешённом ответе наружу попадают только <code>id</code> и отображаемое имя. <code>ownerId</code>, <code>tenantId</code> и внутренний <code>reason</code> не становятся частью API-ответа.</p><p>Важна не только строка с условием. <code>subjectId</code>, роль и tenant в примере обозначают уже проверенный контекст. Если реальный обработчик заполнит их из пользовательского JSON или недоверенного заголовка, локальная функция перестанет доказывать нужное свойство. На границе HTTP нужно отдельно проверить подпись и срок жизни токена, а затем передать результат проверки в policy.</p><h2>Запрос к хранилищу должен знать область</h2><p>Для tenant-системы область лучше включать в запрос к хранилищу: <code>SELECT id, owner_id, tenant_id FROM profiles WHERE id = :id AND tenant_id = :tenant</code>. Так репозиторий не возвращает запись из чужой области обычному обработчику. Это не отменяет policy: роль, действие и владелец всё равно требуют проверки. Но граница данных появляется раньше сериализации, журналирования и работы с кэшем.</p><p>Проверка владельца после широкого поиска может выглядеть безопасно, если ответ всегда отбрасывается. Она всё равно усложняет защиту: объект уже попал в память процесса, ошибочный лог или промежуточный кэш. В запросах на изменение дополнительно разрешайте только перечисленные свойства. Поле <code>ownerId</code>, tenant и признаки статуса не должны обновляться массовой привязкой тела запроса.</p><h2>401, 403 и 404 — часть контракта</h2><p>Код ответа не заменяет policy, но помогает не смешивать границы. Отсутствующие или недействительные учётные данные относятся к <code>401</code>. Сервер понял запрос, но не разрешил действие над известным объектом, — это кандидат на <code>403</code>. Когда публикация самого факта существования записи опасна, сервис может вернуть <code>404</code> и для запрещённого объекта. Выбор должен быть единым для endpoint-а и модели угроз, а не случайной реакцией разных обработчиков.</p><ul><li><code>401</code>: слой аутентификации не получил действительных учётных данных; до объектной политики запрос обычно не дошёл.</li><li><code>403</code>: субъект распознан, но комбинация роли, действия, объекта или области не разрешена.</li><li><code>404</code>: запись отсутствует либо сервис не раскрывает, что запрещённый ресурс существует.</li></ul><p>Внешнее сообщение можно сделать одинаковым для нескольких отказов, а внутренний журнал — полезным для расследования. В него не должны попадать полный токен, пароль, секретные query-параметры и лишние персональные поля. Логируйте короткий класс причины, идентификатор операции и минимальный контекст, который разрешено хранить.</p><h2>Проверяем отрицательный путь без UI</h2><p>Положительный тест на свой профиль показывает только одну разрешённую строку. Он не проверяет, что граница закрыта для соседа, другой области или неизвестной роли. Матрица должна включать по меньшей мере такие наблюдаемые случаи:</p><div class=\"table-scroll\"><table><caption>Матрица отрицательных проверок</caption><thead><tr><th scope=\"col\">Вход</th><th scope=\"col\">Ожидаемое решение</th><th scope=\"col\">Что подтверждает тест</th></tr></thead><tbody><tr><td><code>user u-1 → profile u-1</code>, <code>read</code></td><td><code>allow / 200</code></td><td>Легитимный сценарий не сломан</td></tr><tr><td><code>user u-1 → profile u-2</code>, <code>read</code></td><td><code>deny</code>, внешний <code>403</code> или <code>404</code></td><td>Подмена id не даёт чужой объект</td></tr><tr><td><code>user u-1 → profile u-3</code>, другой tenant</td><td><code>deny</code>, без данных записи</td><td>Область участвует в решении</td></tr><tr><td><code>guest u-1 → profile u-1</code></td><td><code>deny</code></td><td>Неизвестная роль не получает доступ</td></tr><tr><td><code>user u-1 → audit</code> или запрещённый метод</td><td><code>deny</code> до бизнес-операции</td><td>Роль и действие не подменяются URL</td></tr></tbody></table></div><p>Запускайте такие проверки через настоящий маршрут и через прямой HTTP-клиент, без кликов в браузере. Сравнивайте код, тело и набор полей ответа для разрешённого и запрещённого случаев. Отдельно проверьте прокси и кэш: персональный ответ не должен попасть под общим ключом к следующему субъекту.</p><h2>Порядок внедрения</h2><ol><li>Выберите один endpoint и запишите его субъект, метод, действие, идентификатор объекта и внешний ответ.</li><li>Для каждого поля укажите доверенный источник: сессия, claims, маршрут или серверная запись.</li><li>Загрузите объект в нужной области. Для tenant-системы включите tenant в запрос к репозиторию.</li><li>Опишите явные allow-правила для роли, действия и объекта. Последним результатом оставьте <code>deny</code>.</li><li>Не принимайте владельца, tenant и роль из тела запроса, параметров URL или клиентского заголовка.</li><li>Сначала добавьте тест своего объекта, затем чужого объекта, другой области, неизвестной роли и запрещённого метода.</li><li>Согласуйте внешний контракт <code>401</code>/<code>403</code>/<code>404</code> и не возвращайте внутреннюю причину отказа без необходимости.</li><li>Проверьте сериализацию, кэш, журнал и повторный вызов после изменений middleware, репозитория или policy.</li></ol><h2>Ограничения и критерий готовности</h2><p>Учебная функция не проверяет подпись JWT, срок жизни сессии, CSRF, атомарность транзакции, согласованность реплик, правила администратора, правила для отдельных полей и реальный кэш. Случайные или длинные идентификаторы могут затруднить перебор, но не заменяют проверку разрешений. Ни одна библиотека не знает автоматически, кому принадлежит объект в вашей предметной области.</p><p>Для одного endpoint-а работа готова, когда интеграционный тест через реальный маршрут разрешает свой объект, отказывает чужому и неизвестной роли, проверяет другую область и сравнивает тело ответа. У запретной ветки нет приватных полей, а решение не зависит от видимости UI-кнопки. Следующий шаг — взять один endpoint в окружении, похожем на production, записать матрицу «субъект × действие × объект» и сохранить эти отрицательные случаи как регрессионные тесты.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP API1:2023 — Broken Object Level Authorization</a> — описывает BOLA, подмену идентификатора и проверку действия над каждой записью. Граница: материал не задаёт роли, tenant-модель и внешний код ответа конкретного приложения.</li><li><a href=\"https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP API5:2023 — Broken Function Level Authorization</a> — разделяет доступ к функции и объекту и рекомендует deny-by-default с явными разрешениями. Граница: рекомендации API5 не заменяют матрицу владельцев и объектов.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener noreferrer\">OWASP Authorization Cheat Sheet</a> — связывает least privilege, проверку на каждом запросе и unit/integration-тесты с моделью авторизации. Граница: памятка не подтверждает безопасность конкретного endpoint-а.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html#name-403-forbidden\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110 — HTTP Semantics</a> — определяет смысл ответов <code>401</code>, <code>403</code> и <code>404</code>, включая возможность скрывать существование запрещённого ресурса через <code>404</code>. Граница: RFC не выбирает policy и модель угроз продукта.</li></ul>"
|
||
}
|