8 lines
23 KiB
JSON
8 lines
23 KiB
JSON
{
|
||
"index": 13,
|
||
"slug": "editorial-2027-08-field-security-capstone",
|
||
"title": "Авторизация, которую можно доказать: от симптома до отрицательного теста",
|
||
"excerpt": "Как связать security-требование, субъекта, объект и действие, а затем доказать на HTTP-границе, что чужой идентификатор не даёт доступ.",
|
||
"contentHtml": "<p>Пользователь открывает свой профиль, меняет идентификатор в URL и получает профиль другого пользователя. Ответ — 200, токен действителен, а интерфейс не показывает кнопку для чужого объекта. Цена ошибки — горизонтальная эскалация: один аккаунт читает или меняет данные другого. Исправление XSS в форме этот путь не закрывает. Экран может быть аккуратным, а endpoint — уязвимым.</p>\n<p>Разберём узкую задачу: endpoint принимает ссылку на объект и должен решить, может ли конкретный субъект выполнить конкретное действие. Результат будет считаться доказанным только при совпадении четырёх вещей: требования, источника входных данных, фактического решения и внешнего HTTP-ответа. Это не аудит всего приложения и не обещание полной безопасности.</p>\n<h2>Почему успешный вход ничего не доказывает</h2>\n<p>Аутентификация отвечает на вопрос «кто отправил запрос». Авторизация отвечает на другой вопрос: «может ли этот субъект выполнить это действие над этим объектом». Валидная сессия решает только первую часть. Если handler получает <code>id</code> из URL, а затем делает поиск только по этому <code>id</code>, он может вернуть запись, которая субъекту не принадлежит.</p>\n<p>Это классический разрыв между доступом к функции и доступом к объекту. Пользователь вправе вызвать <code>GET /profiles/:id</code>, но не вправе выбрать любой <code>:id</code>. Если он меняет параметр и получает чужую запись, это object-level проблема; если обычный пользователь вызывает административный endpoint, это уже function-level проблема. Названия полезны только тогда, когда ведут к разным проверкам.</p>\n<p>Скрытая ссылка, disabled-кнопка и проверка в браузере не являются границей доверия. Запрос можно повторить через HTTP-клиент. Сервер должен проверить право в каждом пути, который читает, изменяет или удаляет объект по данным клиента. Непредсказуемый UUID уменьшает угадывание, но не превращает отсутствие policy в разрешение.</p>\n<h2>Требование превращается в проверяемую строку</h2>\n<p>Фраза «пользователь видит только свой профиль» слишком коротка для теста. Зафиксируем контракт одного read-only endpoint-а: аутентифицированный <code>user</code> читает профиль своего tenant-а, чужой профиль получает отказ, а запрос без действующих credentials не доходит до object policy. Для примера выбираем единый внешний ответ: 200 для allow, 403 для authenticated deny и 401 для отсутствующей или недействительной аутентификации. Если продукт скрывает существование чужого объекта кодом 404, это должна быть отдельная осознанная версия контракта, одинаковая в матрице и тестах.</p>\n<div class='table-scroll'><table><caption>Матрица входов для endpoint-а профиля</caption><thead><tr><th scope='col'>Субъект</th><th scope='col'>Действие</th><th scope='col'>Объект</th><th scope='col'>Ожидаемый ответ</th><th scope='col'>Доверенный источник</th></tr></thead><tbody><tr><td>u-1, t-1</td><td>read</td><td>profile u-1, t-1</td><td>allow, 200</td><td>session + database</td></tr><tr><td>u-1, t-1</td><td>read</td><td>profile u-2, t-1</td><td>deny, 403</td><td>session + database</td></tr><tr><td>u-1, t-1</td><td>read</td><td>profile u-3, t-2</td><td>deny, 403</td><td>session + database</td></tr><tr><td>нет valid credentials</td><td>read</td><td>profile u-1, t-1</td><td>deny, 401</td><td>auth layer</td></tr><tr><td>u-1, t-1</td><td>delete</td><td>profile u-1, t-1</td><td>deny, 403</td><td>route policy</td></tr></tbody></table></div>\n<p>Статусы здесь не взяты «по привычке». Согласно RFC 9110, 401 означает отсутствие действительных authentication credentials и требует <code>WWW-Authenticate</code>; 403 означает, что сервер понял запрос, но отказывается его выполнять; сервер может использовать 404, если не хочет раскрывать существование запрещённого ресурса. Поэтому в реальном проекте сначала фиксируют policy и модель угроз, а уже потом выбирают публичный код.</p>\n<h2>Где должен находиться контроль</h2>\n<p>Нарисуйте путь данных до того, как писать условие. <code>subjectId</code>, роль и tenant должны прийти из проверенного контекста аутентификации. Идентификатор ресурса приходит из маршрута, но сам объект и его владелец загружаются сервером. Действие выводится из маршрута и метода, а не из поля, которое клиент может заменить. Для multi-tenant системы tenant входит в область выборки, иначе проверка владельца может оказаться слишком поздней.</p>\n<p>Практически это означает: не делайте сначала общий запрос «найди профиль по id», а затем не решайте судьбу уже загруженной записи в случайном слое. Если хранилище позволяет, ограничьте выборку субъектом и tenant-ом сразу. Если нужна отдельная policy, передайте ей server-side resource. <code>ownerId</code> из JSON описывает желание клиента, но не доказывает владение.</p>\n<pre><code>function authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (\n role === 'user' &&\n action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId\n ) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}</code></pre>\n<p>Это учебная policy-функция, а не готовое middleware. Она не проверяет подпись токена, срок сессии, CSRF, rate limit, кэш или журналирование. Её полезная граница уже видна: решение зависит от роли, действия, tenant и server-side владельца; неизвестная комбинация не проходит через случайную ветку allow.</p>\n<figure><img src='/assets/editorial/2027/security-capstone-2027-review-handoff-loop.svg' alt='Цикл доказательства авторизации: требование связывается с субъектом и объектом, затем проходит отрицательный HTTP-тест и возвращается в разбор при расхождении.' loading='lazy' /><figcaption>Проверка замыкается только после сравнения ожидаемого и фактического HTTP-ответа. Unit-решение policy — промежуточное evidence, а не доказательство маршрута.</figcaption></figure>\n<h2>Отрицательный тест должен быть исполняемым</h2>\n<p>Happy path показывает, что владелец не заблокирован. Он не показывает, что соседний объект закрыт. Минимальный тест держит рядом имя случая, вход и ожидаемое решение. Ниже полностью самодостаточный файл для Node.js: сохраните его как <code>authorization-policy.test.js</code> и выполните командой <code>node authorization-policy.test.js</code>. В нём нет сети и реальных данных, поэтому зелёный результат относится только к этой функции.</p>\n<pre><code>const assert = require('node:assert/strict');\n\nfunction authorize({ role, subjectId, tenantId, action, resource }) {\n if (!role || !subjectId || !tenantId || !resource) {\n return { decision: 'deny', reason: 'incomplete-context' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' &&\n resource.tenantId === tenantId &&\n resource.ownerId === subjectId) {\n return { decision: 'allow', reason: 'same-tenant-owner' };\n }\n\n return { decision: 'deny', reason: 'default-deny' };\n}\n\nconst cases = [\n ['owner reads own profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'allow', reason: 'same-tenant-owner' }],\n ['owner cannot read foreign profile',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-2', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['cross-tenant profile is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-2' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unknown role is denied',\n { role: 'guest', subjectId: 'u-1', tenantId: 't-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n ['unsupported action is denied',\n { role: 'user', subjectId: 'u-1', tenantId: 't-1', action: 'delete',\n resource: { kind: 'profile', ownerId: 'u-1', tenantId: 't-1' } },\n { decision: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n assert.deepEqual(authorize(input), expected, name);\n console.log('PASS', name);\n}</code></pre>\n<p>Этот тест теперь воспроизводим как unit-проверка, но не маскирует границу. Следующий слой должен вызвать реальный handler и передать ему две тестовые identities, два tenant-а и два объекта. Для read-only endpoint-а безопасный шаблон запроса выглядит так:</p>\n<pre><code>curl -sS -i \\\n -H 'Authorization: Bearer <test-token-u-1>' \\\n 'https://test.example.test/api/profiles/u-2'</code></pre>\n<p>На тестовом окружении ожидайте выбранный контракт, здесь — 403 и отсутствие данных <code>u-2</code> в теле. Второй запрос к <code>u-1</code> должен дать 200, запрос без credentials — 401 с <code>WWW-Authenticate</code>. Не подставляйте реальные токены и не проверяйте чужие объекты без письменного разрешения: воспроизводимость не расширяет область допустимых действий.</p>\n<h2>Проверяем не только policy</h2>\n<p>Расхождение между unit и HTTP возникает на стыках. Adapter может превратить deny в 200, serializer — добавить лишнее поле, а кэш — вернуть ответ, созданный для другого субъекта. Для приватного ответа ключ должен учитывать все атрибуты, влияющие на право, включая tenant и subject, либо ответ не должен кэшироваться общим слоем. Это решение проверяют фактическим повтором, а не чтением названия ключа.</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>Чужой профиль отвечает 200</td><td>Проверили токен, но не объект</td><td>u-1 запрашивает профиль u-2 напрямую</td><td>Добавить object-level deny и HTTP-тест</td></tr><tr><td>u-1 видит объект tenant t-2</td><td>Tenant ограничили после общего чтения</td><td>Повторить запрос двумя tenant-ами</td><td>Включить tenant в выборку и policy</td></tr><tr><td>Policy вернула deny, HTTP дал 200</td><td>Adapter или handler потерял решение</td><td>Сравнить policy result, статус и body</td><td>Сделать mapping явным и тестируемым</td></tr><tr><td>Чужой ответ появляется после прогрева кэша</td><td>Ключ не содержит permission context</td><td>Поменять субъекта после первого запроса</td><td>Разделить ключ или отключить общий cache</td></tr><tr><td>403 раскрывает существование записи</td><td>Публичный ответ повторяет внутреннюю причину</td><td>Сравнить чужой и отсутствующий объект</td><td>Выбрать 403 или 404 по модели угроз; тело сделать одинаково безопасным</td></tr><tr><td>UI-тест зелёный, endpoint уязвим</td><td>Проверяли только видимость кнопки</td><td>Вызвать маршрут без браузера</td><td>Оставить HTTP-проверку в CI</td></tr></tbody></table></div>\n<p>Логи тоже относятся к контракту доказательства. Записывайте идентификатор тестового случая, результат и безопасный correlation id. Не кладите в журнал bearer token, пароль, полный URL с секретом или лишние персональные данные. Внешний ответ должен помогать клиенту, а внутренний reason — расследованию; это не одно и то же поле.</p>\n<h2>Порядок проверки одного endpoint-а</h2>\n<ol><li>Запишите маршрут, HTTP-метод, действие и тип объекта. Выберите тестовые identities, которыми разрешено пользоваться.</li><li>Составьте матрицу: свой объект, чужой объект того же tenant-а, объект другого tenant-а, отсутствующая аутентификация и недопустимое действие.</li><li>Назначьте источник каждого поля. Subject и роль приходят из auth context, owner и tenant — из серверного объекта, action — из маршрута.</li><li>Проверьте запрос к хранилищу. Убедитесь, что tenant boundary не добавляется после выдачи данных и что объект не строится из client body.</li><li>Напишите unit-тест policy с явным default deny. Отдельно проверьте реальный handler и сериализацию.</li><li>Повторите все строки через HTTP без UI. Сохраните статус, безопасное тело, заголовки и идентификатор случая.</li><li>Прогрейте кэш и повторите пары разными субъектами. Для mutation добавьте проверку отсутствия изменения после deny.</li><li>Зафиксируйте расхождение как дефект policy, data access, mapping, cache или контракта. Не удаляйте отрицательный тест ради зелёного pipeline.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта схема не проверяет подпись и срок жизни токена, MFA, CSRF, права на отдельные поля, загрузку файлов, SSRF, rate limit, гонки, репликацию базы, reverse proxy и корректность всех альтернативных маршрутов. Для массового запроса проверяйте каждый объект, а не только первый. Для администратора описывайте отдельные grants: роль сама по себе не означает право читать всё.</p>\n<p>Готовность одного endpoint-а можно сформулировать строго: есть версия требования, доверенный источник subject/tenant/owner/action, серверная object-level проверка, unit-отказ и успешный HTTP-тест владельца. Прямой запрос к чужому и cross-tenant объекту возвращает закреплённый deny-ответ; запрос без credentials проходит auth-контракт; после кэша результат не меняется между субъектами. Это доказательство выбранной границы, а не сертификат безопасности приложения.</p>\n<h2>Проверяемые источники</h2><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><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></td><td>Изменение object ID может обойти контроль; endpoint, работающий с объектом по client input, должен проверять право на этот объект; нужны тесты authorization.</td><td>Это категория риска API и рекомендации, а не проверка конкретного приложения и не гарантия покрытия.</td></tr><tr><td><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></td><td>Function-level доступ нужно явно разрешать ролям, а неизвестные комбинации отклонять по умолчанию; это отдельная проблема от object-level доступа.</td><td>Материал не выбирает роли, tenant-модель или публичные HTTP-коды конкретного продукта.</td></tr><tr><td><a href='https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References' target='_blank' rel='noopener noreferrer'>OWASP WSTG-ATHZ-04: Testing for Insecure Direct Object References</a></td><td>Нужно картировать прямые ссылки на объекты, менять параметр и сравнивать доступ как минимум для двух пользователей с разными объектами.</td><td>Latest-версия руководства может обновляться; это методика тестирования, а не compliance standard и не разрешение тестировать чужие системы.</td></tr><tr><td><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>IETF RFC 9110, раздел 15.5</a></td><td>Смысл 401, 403 и 404: credentials, отказ в выполнении и допустимое сокрытие существования ресурса; для 401 требуется <code>WWW-Authenticate</code>.</td><td>RFC описывает семантику HTTP, но не задаёт application policy, модель угроз или выбор между 403 и 404 для конкретного сервиса.</td></tr></tbody></table></div>"
|
||
}
|