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

8 lines
23 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": 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' &amp;&amp;\n action === 'read' &amp;&amp;\n resource.kind === 'profile' &amp;&amp;\n resource.tenantId === tenantId &amp;&amp;\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' &amp;&amp; action === 'read' &amp;&amp;\n resource.kind === 'profile' &amp;&amp;\n resource.tenantId === tenantId &amp;&amp;\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 &lt;test-token-u-1&gt;' \\\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>"
}