{ "index": 13, "slug": "editorial-2027-08-field-security-capstone", "title": "Авторизация, которую можно доказать: от симптома до отрицательного теста", "excerpt": "Доступ к своему профилю разрешён, к чужому — тоже. Разбираем, как связать security-требование, объектную политику, HTTP-проверку и доказательство отказа.", "contentHtml": "
Пользователь открывает свой профиль, а затем меняет один идентификатор в URL и получает профиль другого пользователя. В журналах виден успешный ответ 200. В интерфейсе нет кнопки для чужого объекта, поэтому ручная проверка проходит. Цена ошибки — горизонтальная эскалация привилегий: один аккаунт читает или меняет данные другого. Исправление XSS в форме не закрывает этот путь. Экран может быть безопасным, а endpoint — нет.
\nТезис статьи простой: security-проверка готова только тогда, когда требование связано с конкретным субъектом, объектом и действием, а разрешение и отказ подтверждены на границе HTTP. Строка «авторизация проверена» ничего не доказывает. Доказательство содержит вход, ожидаемый ответ, фактический ответ и понятную причину расхождения.
\nПроверка владельца обычно начинается с положительного сценария. Пользователь u-1 запрашивает profile u-1 и получает 200. Этот тест показывает, что легитимный запрос работает. Он не показывает, что policy остановит profile u-2. Без отрицательной строки система может разрешать оба запроса.
\nПричина часто появляется на стыке слоёв. Handler получает id из URL. Middleware проверяет, что токен действителен. Репозиторий ищет запись по одному id. Если проверка владельца не входит в запрос или выполняется после чтения, соседний объект уже попал в ответ. Кэш способен усилить ошибку: ответ для u-1 сохранится под ключом profile:42 и станет доступен u-2.
Нужна объектная проверка. Субъект приходит из проверенного контекста сессии или токена. Объект приходит из маршрута и базы. Действие задаёт endpoint. Решение принимает код рядом с границей доступа, а не только компонент интерфейса.
\nФраза «пользователь видит только свой профиль» слишком короткая для теста. Разложите её на четыре поля: кто действует, что делает, над каким объектом и какой результат допустим. Для профиля минимальная политика выглядит так: user может read свой profile; user не может read чужой profile; неизвестная роль получает deny; admin может read audit, если это входит в контракт.
\n| Субъект | Действие | Объект | Ожидаемый результат |
|---|---|---|---|
| user u-1 | read | profile u-1 | allow, 200 |
| user u-1 | read | profile u-2 | deny, 403 или 404 |
| user u-1 | read | audit | deny, 403 или 404 |
| unknown | read | profile u-1 | deny, 401 или 403 |
Статус зависит от контракта. 403 сообщает, что запрос распознан, но запрещён. 404 иногда скрывает существование чужого объекта. Важно не выбрать «правильный» код вообще, а закрепить один вариант для конкретного endpoint и проверить его на внешней границе.
\nИдентификатор требования должен быть стабильным. Например, AUTH-PROFILE-01 связывает описание политики, тесты и запись изменения. Если требования ссылаются на OWASP ASVS, фиксируйте версию стандарта в ссылке. Идентификатор без версии может начать означать другой текст после обновления стандарта.
Политика должна сначала отказывать, а затем явно разрешать узкие комбинации. Для профиля нельзя проверять только роль. Два пользователя имеют одну роль, но разные объекты. Нельзя проверять только наличие токена. Валидная сессия не даёт доступ ко всем ресурсам.
\nfunction decide({ role, subjectId, action, resource }) {\n if (!role || !subjectId || !resource) {\n return { status: 'deny', reason: 'invalid-input' };\n }\n\n if (role === 'admin' && action === 'read' && resource.kind === 'audit') {\n return { status: 'allow', reason: 'role-permission' };\n }\n\n if (role === 'user' && action === 'read' &&\n resource.kind === 'profile' && resource.ownerId === subjectId) {\n return { status: 'allow', reason: 'object-ownership' };\n }\n\n return { status: 'deny', reason: 'default-deny' };\n}\nКод выше — учебный пример. Он не является готовым middleware и не проверяет токен, CSRF, tenant, срок сессии, rate limit, кэш или журналирование. Его задача — показать форму решения: функция принимает субъект, действие и объект; результат содержит статус и безопасную причину. Не передавайте в клиент внутренние сведения о policy. Причину используйте внутри теста и журнала с учётом правил о чувствительных данных.
\nВ реальном handler сначала извлеките субъект из уже проверенного контекста, затем загрузите объект с учётом tenant и вызовите policy. Не принимайте ownerId из тела запроса как доказательство владения. Клиент может изменить это поле. Источник владельца — доверенная запись на сервере.
Отказ не должен считаться исключением теста. Он должен быть ожидаемым результатом конкретного входа. Для каждой строки зафиксируйте имя, вход и результат. Так падение отвечает на вопрос: policy разрешила чужой объект, middleware потерял subject или handler вернул неверный статус.
\nimport assert from 'node:assert/strict';\n\nconst cases = [\n ['owner reads own profile',\n { role: 'user', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1' } },\n { status: 'allow', reason: 'object-ownership' }],\n ['owner cannot read foreign profile',\n { role: 'user', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-2' } },\n { status: 'deny', reason: 'default-deny' }],\n ['unknown role is denied',\n { role: 'guest', subjectId: 'u-1', action: 'read',\n resource: { kind: 'profile', ownerId: 'u-1' } },\n { status: 'deny', reason: 'default-deny' }],\n];\n\nfor (const [name, input, expected] of cases) {\n assert.deepEqual(decide(input), expected, name);\n console.log('PASS', name);\n}\nЭтот фрагмент также учебный. Его можно выполнить только после добавления функции decide из предыдущего фрагмента. Он не обращается к базе и не доказывает безопасность HTTP-слоя. Если такой unit-тест зелёный, это доказывает только выбранные ветки функции. Следующий тест должен вызвать реальный handler с двумя субъектами и двумя объектами.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Чужой профиль отвечает 200 | Проверили токен, но не владельца объекта | Повторить запрос с u-1 к profile u-2 | Добавить object-level deny в handler или policy |
| User читает audit | Роль проверяют по наличию, а не по разрешению | Запросить ресурс с user и admin | Сделать список разрешённых role/action/resource явным |
| Неизвестная роль получает доступ | Ветка по умолчанию разрешает запрос | Подать роль guest или пустую роль | Вернуть deny до всех allow-веток |
| После исправления UI тест зелёный, API уязвим | Проверяли только скрытую кнопку | Вызвать endpoint напрямую без браузерного интерфейса | Перенести контроль на серверную границу и добавить HTTP-тест |
| Иногда виден чужой ответ | Ключ кэша не содержит tenant или subject | Повторить запрос после прогрева кэша разными пользователями | Разделить ключи или запретить кэширование приватного ответа |
Матрица не заменяет модель угроз. Она не проверяет CSRF для изменяющего запроса, подделку токена, SSRF, загрузку файлов, гонки, обход маршрутизации и настройку reverse proxy. Для multi-tenant системы добавьте tenant в субъект и объект. Для массовых операций проверьте каждый объект, а не только первый. Для файлов отдельная проверка должна учитывать тип содержимого, имя, хранение вне webroot и выдачу через авторизованный обработчик.
\nНе считайте зелёный unit-тест доказательством всей защиты. Отрицательный путь может сломаться между слоями: policy вернула deny, но adapter превратил его в 200; handler вернул 403, но кэш отдал старый 200; база загрузила запись другого tenant до проверки. Поэтому критерий должен проходить через реальный маршрут.
\nДля выбранного endpoint есть версия требования, матрица входов и автоматическая проверка. Запрос владельца получает закреплённый allow-ответ. Запрос к чужому объекту, неизвестная роль и недопустимое действие получают закреплённый deny-ответ. Прямой HTTP-вызов подтверждает это без UI. Повторная проверка после прогрева кэша не меняет результат между субъектами. В логе остаётся безопасный идентификатор проверки, но не секрет и не лишние персональные данные.
\nЕсли хотя бы одна строка не имеет фактического результата, проверка не закончена. Если результат зависит от случайного состояния, сначала стабилизируйте окружение и зафиксируйте границу. Готовность — это не отсутствие замечаний в чек-листе. Это воспроизводимый отказ на запрещённом входе и воспроизводимое разрешение на допустимом.
\n