Files
progcode/editorial/agent-rewrites/014.json
T

8 lines
20 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>"
}