8 lines
16 KiB
JSON
8 lines
16 KiB
JSON
{
|
||
"index": 14,
|
||
"slug": "editorial-2027-08-mechanism-security-capstone",
|
||
"title": "Проверка логина не защищает объект: строим deny-by-default",
|
||
"excerpt": "Пользователь может быть правильно аутентифицирован и всё равно не иметь права читать выбранную запись. Разбираем объектную авторизацию, проверку владельца и отрицательный путь от URL до ответа 403.",
|
||
"contentHtml": "<p>Пользователь входит в систему, открывает <code>/profile?id=u-1</code>, меняет один символ и получает профиль <code>u-2</code>. Токен остаётся действительным. Сервер проверяет его наличие и отдаёт найденную запись. Симптом выглядит как обычный доступ к странице, но это горизонтальная эскалация прав.</p><p>Цена ошибки — утечка персональных данных, изменение чужих объектов и потеря границы между tenant-ами. Чем больше endpoint-ов строится по схеме «взяли id из URL, нашли запись, вернули ответ», тем больше таких точек появляется. Скрытая кнопка в интерфейсе не помогает: запрос можно повторить вручную.</p><h2>Тезис: логин устанавливает субъекта, но не разрешение</h2><p>Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Между вопросами стоят роль, область доступа и принадлежность записи. Если сервер не проверяет их отдельно, валидная сессия превращается в пропуск к любому известному идентификатору.</p><p>Надёжное решение принимает четыре значения: субъект, действие, объект и контекст политики. Субъект приходит из проверенной сессии или токена. Действие выводится из маршрута и HTTP-метода. Объект загружается сервером. Владелец, tenant и чувствительность объекта берутся из доверенных данных, а не из тела запроса. Неизвестная комбинация получает deny.</p><h2>Механизм объектной проверки</h2><p>Сначала middleware или слой сессии устанавливает <code>subjectId</code> и роль. Затем handler разбирает путь и получает идентификатор ресурса. Репозиторий возвращает объект вместе с его владельцем и tenant-ом. Policy layer сравнивает эти поля с субъектом и разрешает только явно описанные действия. UI может скрыть недоступную кнопку, но решение всё равно принимает сервер.</p><p>Правило владельца нельзя заменить проверкой роли. Два пользователя могут иметь одну роль <code>user</code>, но видеть разные профили. Роль говорит о классе полномочий. Владелец говорит о конкретном объекте. Для администратора нужен отдельный allow-список: «читать аудит» не равно «читать любую персональную запись», а «администратор» не должно означать «разрешено всё».</p><p>Нельзя принять <code>ownerId</code> из JSON и использовать его как доказательство владения. Клиент сообщает, какой объект он хочет выбрать. Сервер сам читает владельца из базы или доменного сервиса. Для multi-tenant системы запрос к хранилищу должен сразу включать tenant boundary. Если сначала получить запись без ограничения области, последующая проверка уже может оказаться слишком поздней.</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>id</code> в URL показывает чужой профиль</td><td>Проверили сессию, но не владельца объекта</td><td>Отправить запрос для своего и соседнего идентификатора</td><td>Сравнивать <code>subjectId</code> с владельцем записи; чужой объект отклонять</td></tr><tr><td>Пользователь читает audit endpoint</td><td>Роль проверяют слишком широко</td><td>Вызвать endpoint с ролью <code>user</code> напрямую, без UI</td><td>Описать ресурс и действие в отдельном allow-правиле</td></tr><tr><td>Неизвестная роль получает 200</td><td>Ветка по умолчанию пропускает запрос</td><td>Удалить или подменить claim и повторить вызов</td><td>Сделать результатом по умолчанию <code>deny</code></td></tr><tr><td>Чужой ответ появляется после кэширования</td><td>Ключ кэша не содержит субъекта или области</td><td>Повторить запрос разными субъектами и сравнить тело</td><td>Разделить кэш по permission context либо не кэшировать ответ</td></tr><tr><td>403 раскрывает существование записи</td><td>Внешний ответ повторяет внутреннюю причину</td><td>Сравнить ответ для отсутствующего и чужого объекта</td><td>Выбрать 404 или 403 по модели угроз, причину логировать безопасно</td></tr></tbody></table></div><figure><img src='/assets/editorial/2027/security-capstone-2027-control-evidence-matrix.svg' alt='Матрица авторизации связывает субъекта, роль, действие, объект и владельца с решением allow или deny' loading='lazy' /><figcaption>Решение строится на серверных атрибутах объекта. Изменение идентификатора в запросе не меняет его владельца и tenant.</figcaption></figure><h2>Учебный endpoint</h2><p>Ниже — маленький пример на Node.js. Профили хранятся в <code>Map</code>, а субъект и роль передаются заголовками только для учебного сценария. Реальная система должна получать их из проверенной сессии, JWT или другого принятого механизма. Пример не подключается к production и не доказывает безопасность конкретного приложения.</p><pre><code>const profiles = new Map([['u-1', { owner: 'u-1', tenant: 't-1' }], ['u-2', { owner: 'u-2', tenant: 't-1' }]]); function decide({ role, subjectId, tenant, resource, action }) { if (role === 'admin' && resource.kind === 'audit' && action === 'read') return { status: 200, reason: 'admin-audit' }; if (role === 'user' && resource.kind === 'profile' && action === 'read' && resource.tenant === tenant && resource.owner === subjectId) return { status: 200, reason: 'owner' }; return { status: 403, reason: 'default-deny' }; } const id = new URL(request.url, 'http://local').searchParams.get('id'); const profile = profiles.get(id); const resource = profile && { kind: 'profile', ...profile }; const result = resource ? decide({ role, subjectId, tenant, resource, action: 'read' }) : { status: 404, reason: 'not-found' };</code></pre><p>Условие для профиля проверяет роль, действие, tenant и владельца. Запрос от <code>u-1</code> к <code>u-1</code> получает 200. Тот же субъект к <code>u-2</code> получает 403. Если tenant отличается, результат также deny. Идентификатор из URL только выбирает запись; он не назначает ей владельца.</p><p>Проверка существования объекта требует отдельного решения. В примере отсутствующий профиль даёт 404, а найденный чужой — 403. В некоторых системах оба случая наружу превращают в 404, чтобы не раскрывать наличие записи. Это не универсальное правило. Выбор зависит от модели угроз. Внутри сохраняйте короткий класс причины и не пишите в журнал полный токен или секретные параметры.</p><h2>Отрицательный путь важнее happy path</h2><p>Разрешённый запрос показывает, что легитимный сценарий работает. Он не показывает, что граница закрыта. Минимальный набор должен включать чужой объект, запрещённое действие, неизвестную роль, другой tenant, отсутствующий ресурс и повторный вызов через прямой HTTP-клиент. Для mutation добавьте проверку метода и защиту от повторной операции. Для чтения проверьте кэш и сериализацию ответа.</p><p>Проверяйте policy без интерфейса. Если тест кликает только по видимой кнопке, он не проверяет handler. Отправьте запрос с изменённым <code>id</code>, вручную задайте роль и удалите обязательный claim. Эти входы учебные и не должны содержать реальные идентификаторы или секреты. Их смысл — показать отрицательную ветку, а не воспроизвести доступ к настоящим данным.</p><h2>Порядок внедрения</h2><ol><li>Составьте карту одного endpoint-а: субъект, HTTP-метод, действие, объект, tenant и внешний ответ.</li><li>Определите доверенный источник каждого поля. Не берите роль, владельца и tenant из пользовательского body.</li><li>Загрузите объект в правильной области доступа. Для tenant-системы включите tenant в запрос к хранилищу.</li><li>Запишите явные allow-правила для ресурса и действия. Оставьте <code>deny</code> результатом для неизвестной комбинации.</li><li>Добавьте тесты своего объекта, чужого объекта, запрещённого действия, неизвестной роли и другой области.</li><li>Вызовите handler напрямую, без UI, и проверьте код, тело, кэш и отсутствие лишних данных в ответе.</li><li>Настройте безопасное журналирование: причина должна помогать расследованию, но не содержать токены, пароли и полный URL с секретами.</li><li>Повторите отрицательные тесты после изменения middleware, репозитория, политики и ключа кэша.</li></ol><h2>Ограничения и критерий готовности</h2><p>Учебный код использует заголовки вместо настоящей аутентификации и <code>Map</code> вместо базы. Он не проверяет срок жизни токена, подпись JWT, CSRF, race condition, права на поля, согласованность реплик и поведение прокси. Он также не решает, как кэшировать персональный ответ. Эти вопросы требуют отдельных контрактов и тестов.</p><p>Проверка готова для одного endpoint-а, если видны источник <code>subjectId</code>, правило области, серверный способ получения владельца, явное действие и default deny. Интеграционный тест должен показать 200 для разрешённого объекта, отказ для чужого объекта и отказ для неизвестной роли через реальный HTTP-маршрут. Если проходит только unit-тест policy или только проверка UI, работа не готова: граница между запросом, хранилищем и ответом ещё не доказана.</p><h2>Проверяемые источники</h2><ul><li><a href='https://owasp.org/www-project-application-security-verification-standard/' target='_blank' rel='noopener noreferrer'>OWASP Application Security Verification Standard</a> — официальная версия стандарта с проверяемыми требованиями к контролям веб-приложения. Стандарт не выбирает роли и tenant-правила конкретного продукта.</li><li><a href='https://csrc.nist.gov/pubs/sp/800/218/final' target='_blank' rel='noopener noreferrer'>NIST SP 800-218, Secure Software Development Framework</a> — официальная рамка безопасной разработки. Она помогает организовать контроль, но не подтверждает, что данный endpoint уже защищён.</li><li><a href='https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final' target='_blank' rel='noopener noreferrer'>NIST SP 800-53 Rev. 5</a> — каталог security и privacy controls. Каталог не заменяет модель угроз, тесты и проверку фактических прав.</li></ul>"
|
||
}
|