API сегодня - это фактически основа большинства приложений. Мобильные клиенты, веб-интерфейсы, микросервисы внутри инфраструктуры - всё общается через HTTP-endpoint’ы. Поэтому уязвимости на уровне API обычно имеют прямой доступ к данным. Не через обходы интерфейса, а напрямую через запросы.
И вот тут появляется одна из самых неприятных проблем API-безопасности - BOLA (Broken Object Level Authorization).
Если коротко: система проверяет, что пользователь вошёл в систему, но не проверяет, имеет ли он право работать именно с этим объектом.
С точки зрения сервера всё выглядит корректно. Есть валидный токен, пользователь авторизован, endpoint доступен. Запрос проходит. Но сама логика доступа к объектам оказывается слишком простой. Клиент передаёт идентификатор ресурса - сервер его принимает и возвращает данные, не проверяя принадлежность.
В API-архитектуре это встречается постоянно. Endpoint получает ID ресурса, делает запрос в базу и отдаёт результат. Проверка владельца объекта либо отсутствует, либо реализована не везде. Иногда она есть в одном сервисе, но отсутствует в другом. В микросервисной среде такие расхождения появляются довольно легко.
Поэтому BOLA занимает первое место в OWASP API Security Top 10.
Не из-за сложности эксплуатации. Скорее наоборот - потому что её эксплуатация часто оказывается слишком простой. В тестировании API такие проблемы всплывают регулярно. Достаточно изменить идентификатор объекта в запросе и посмотреть, что вернёт сервер.
Типичный пример API-запроса выглядит так:
HTTP:
GET /api/v1/orders/1254
Authorization: Bearer <token>
HTTP:
GET /api/v1/orders/1255
На практике последствия могут быть серьёзнее, чем кажется. Через BOLA часто происходят утечки персональных данных, доступ к чужим заказам, изменение чужих ресурсов или даже полный захват аккаунтов. Особенно если endpoint поддерживает методы PUT, PATCH или DELETE.
Путаницу иногда вызывает терминология. В веб-безопасности давно существует понятие IDOR (Insecure Direct Object Reference) - небезопасная прямая ссылка на объект. BOLA фактически описывает ту же проблему, но применяется именно в контексте API. В API запросы обычно передают ID ресурса напрямую в URL или JSON-структуре, поэтому ошибка авторизации становится особенно заметной.
Если хочется чуть шире посмотреть на контекст, в котором живёт BOLA, и заодно пройтись по остальным рискам из OWASP API Top 10, пригодится наше руководство: Защита API: OWASP Top 10 и примеры на Python.
Анатомия уязвимости
BOLA редко появляется из-за одной конкретной ошибки. Чаще это результат упрощённой логики доступа к данным. Backend получает запрос, извлекает идентификатор объекта из URL или JSON и сразу делает запрос в базу. Если объект существует - возвращает результат. Проверка владельца в этот момент просто не выполняется.Типичная ситуация выглядит примерно так. Endpoint получает
user_id или order_id, затем выполняет запрос к базе:
SQL:
SELECT * FROM orders WHERE id = 1254;
SQL:
SELECT * FROM orders
WHERE id = 1254 AND user_id = current_user_id;
Горизонтальный и вертикальный доступ
В практике тестирования API обычно выделяют два варианта BOLA.| Тип | Что происходит |
|---|---|
| Horizontal | пользователь получает доступ к объектам других пользователей |
| Vertical | пользователь получает доступ к объектам более привилегированной роли |
Вертикальный вариант возникает, когда endpoint доступен нескольким ролям, но сервер не проверяет уровень привилегий. Тогда обычный пользователь может взаимодействовать с административными объектами. На уровне API это может выглядеть совершенно незаметно.
Endpoint
/api/users/42 работает одинаково для всех ролей. Проверка прав доступа выполняется на уровне маршрута, а не на уровне конкретного объекта.Где именно ломается проверка
Если посмотреть на реальные backend-проекты, уязвимость обычно появляется в одном из трёх мест.Первое - отсутствие проверки владельца объекта.
Endpoint извлекает ресурс напрямую по ID.
Второе - разделение логики между сервисами.
Один сервис проверяет права доступа, другой считает, что проверка уже выполнена.
Третье - доверие к данным клиента.
API принимает
user_id из запроса и использует его в запросе к базе.Типичный пример выглядит так:
HTTP:
GET /api/v1/profile?user_id=512
user_id из параметров запроса и использует его напрямую. Если изменить значение - можно получить чужой профиль.BOLA в микросервисной архитектуре
С появлением микросервисов ситуация стала заметно сложнее. В монолитном приложении авторизация обычно централизована. Один модуль проверяет доступ и управляет логикой прав. В микросервисной архитектуре каждый сервис часто реализует собственную проверку.API-шлюз проверяет токен, сервис пользователей проверяет профиль, сервис заказов проверяет доступ к заказам. Между ними появляется слой сетевых вызовов, и именно там иногда теряется проверка владельца объекта.
Например:
Код:
API Gateway → Orders Service → Database
order_id и извлекает запись из базы. Если проверка владельца не реализована внутри сервиса - BOLA появляется автоматически. В сложных системах это может происходить даже на внутренних API. Один сервис запрашивает данные у другого, предполагая, что доступ уже проверен на предыдущем уровне.Эксплуатация BOLA
На практике BOLA ищется довольно приземлённо. Без магии. Без сложных payload’ов. Сначала берётся легитимный запрос от обычного пользователя, потом в нём меняется идентификатор объекта. Дальше смотрим, что ответит API. Если сервер возвращает чужие данные, даёт изменить чужой объект или удалить его - точка найдена.Обычно всё начинается с Burp Suite Repeater. Это самый удобный режим для ручной проверки API-запросов: берём запрос, отправляем его в Repeater, меняем ID и сравниваем ответы. В BOLA как раз важны не только коды ответа, но и сама разница в данных. Иногда сервер возвращает
200 OK и чужой объект. Иногда 403 на часть запросов и 200 на другие. Иногда объект можно читать, но нельзя менять. Вся уязвимость здесь часто раскрывается по кускам, а не одной красивой попыткой.Самый базовый сценарий выглядит так:
HTTP:
GET /api/v1/orders/1254
Authorization: Bearer <user_token>
HTTP:
GET /api/v1/orders/1255
Authorization: Bearer <user_token>
Следующий шаг - методы изменения состояния. Очень часто чтение защищено криво, но запись защищена ещё хуже. Или наоборот. Поэтому после
GET обычно проверяются PUT, PATCH, DELETE.Например:
HTTP:
PATCH /api/v1/users/842
Authorization: Bearer <user_token>
Content-Type: application/json
{
"email": "attacker@example.com"
}
Отдельный момент - где именно лежит идентификатор. Не всегда это сегмент URL. В API объектный ID может быть в query-параметре, в JSON-теле, в заголовке, в nested-структуре. Иногда разработчики закрывают очевидный endpoint
/users/{id}, но забывают про что-то вроде /profile/update с полем userId внутри JSON.Пример такого запроса:
HTTP:
POST /api/v1/profile/update
Authorization: Bearer <user_token>
Content-Type: application/json
{
"userId": 842,
"displayName": "new_name"
}
userId из тела запроса и не сверяет его с субъектом из токена, это тот же BOLA, просто чуть менее заметный.Перебор идентификаторов
После ручной проверки довольно быстро встаёт вопрос: объекты доступны точечно или проблема массовая. И вот тут начинается перебор ID.Самый простой случай - последовательные идентификаторы.
Если объекты идут как
1001, 1002, 1003, то проверка превращается в обычный enumeration - последовательный перебор идентификаторов объекта для поиска доступных записей. Для этого в Burp достаточно отправить запрос в Intruder и прогнать диапазон значений. На таком этапе быстро становится видно, где API отвечает 200, где 403, а где 404. И эта разница сама по себе уже даёт много информации.Если идентификаторы выглядят как UUID, расслабляться рано. Да, UUID - универсальный уникальный идентификатор - сложнее перебрать лобово. Но уязвимость это не убирает. Просто меняется способ поиска объекта. UUID часто утекают через списковые endpoint’ы, логи фронтенда, websocket-события, mobile API, экспорт данных, вложенные ссылки. То есть проблема уже не в предсказуемости значения, а в том, что после получения чужого UUID система всё равно не проверяет право доступа к объекту.
Иногда enumeration выглядит совсем тихо. Например, через список заказов API возвращает набор
order_id, а отдельный endpoint по каждому order_id позволяет получить полный объект без проверки владельца. Формально никакого brute force нет. Практически - есть массовая эксфильтрация данных.Burp Autorize и автоматизация проверки
Для BOLA полезен Autorize - расширение Burp Suite, которое позволяет автоматически повторять запросы с другим токеном и сравнивать ответы. Смысл очень прикладной: мы берём легитимный запрос одного пользователя, а инструмент параллельно отправляет его от имени другого пользователя или с пониженными правами. Если ответы совпадают или отличаются недостаточно сильно, это явный сигнал проверить доступ руками.Такой подход особенно хорошо работает на больших API, где endpoint’ов много и ручная проверка начинает тонуть. Но полностью полагаться на Autorize не стоит. Он помогает быстро находить подозрительные места, а не доказывает BOLA сам по себе. Ответы надо смотреть внимательно: иногда код ответа одинаковый, но объект в теле чужой; иногда различается только одно поле; иногда API возвращает success, но фактически меняет не тот ресурс, который ожидается.
Если хочется чуть больше автоматизации, в Intruder удобно делать не только перебор ID, но и замену токенов, ролей, nested-параметров. Особенно на endpoint’ах, где ID лежит в JSON-массиве или составном объекте. В таких местах BOLA часто сидит глубже обычного URL-паттерна.
На что смотреть в ответах API
В BOLA код ответа - полезный индикатор, но не основной.200 OK на чужой объект -очевидная проблема.404 Not Found вместо 403 Forbidden -тоже интересный признак, потому что сервер может скрывать существование объекта, но делать это непоследовательно.403 на чтение и 200 на обновление - вообще классика жанра, когда разные endpoint’ы написаны разными командами.Хорошая практика при эксплуатации - сравнивать не только статус, но и:
| Что сравнивать | Зачем |
|---|---|
| тело ответа | есть ли чужие данные |
| размер ответа | скрытые различия между объектами |
| время ответа | косвенное подтверждение существования объекта |
| побочные эффекты | изменился ли ресурс после запроса |
Продвинутая эксплуатация: где BOLA прячется глубже обычного
Если BOLA не находится на простых маршрутах вида/users/123, это не значит, что её нет. Чаще это значит, что проверка доступа спрятана неравномерно. На базовых endpoint’ах разработчики уже обожглись и добавили ownership check - проверку, что объект принадлежит текущему пользователю. А вот на вложенных маршрутах, пакетных операциях и GraphQL-резолверах логика часто начинает плыть.И вот там уже становится интереснее.
Вложенные объекты
Маршруты вида/users/123/orders/456 выглядят безопаснее обычных, потому что в них как будто уже есть контекст пользователя. Но это ощущение очень обманчивое. Если backend проверяет только order_id = 456, а user_id = 123 использует как декоративную часть URL, то весь маршрут превращается в ту же самую BOLA, только с лишним сегментом.Пример запроса:
HTTP:
GET /api/v1/users/123/orders/456
Authorization: Bearer <user_token>
123 получает заказ, который на самом деле принадлежит 124, значит сервер проверяет только объект заказа, а не его связь с владельцем. Это очень частая проблема в системах, где вложенные ресурсы обрабатываются через отдельный сервис, а родительский контекст верифицируется только на уровне роутинга.Ещё неприятнее выглядят маршруты вроде:
HTTP:
GET /api/v1/companies/77/users/123/invoices/456
Пакетные endpoint’ы
Пакетные операции - отдельная боль. API получает массив объектов или массив ID и обрабатывает их одной операцией. Такие endpoint’ы удобны для фронтенда и мобильных клиентов, но с точки зрения авторизации это постоянная зона риска.Например:
HTTP:
POST /api/v1/orders/batch
Authorization: Bearer <user_token>
Content-Type: application/json
{
"ids": [1254, 1255, 1256]
}
С пакетным обновлением проблема ещё жёстче:
HTTP:
PATCH /api/v1/users/batch
Authorization: Bearer <user_token>
Content-Type: application/json
{
"items": [
{"id": 842, "role": "user"},
{"id": 843, "role": "admin"}
]
}
BOLA в GraphQL
С GraphQL история ещё тоньше.
GraphQL - это язык запросов к API, где клиент сам описывает, какие поля и какие объекты хочет получить. С точки зрения разработки это удобно. С точки зрения авторизации - местами очень скользко.
Проблема обычно сидит в резолвер - функции, которая обрабатывает конкретное поле или объект GraphQL-запроса. Один резолвер может корректно проверять доступ к объекту, а соседний - просто доставать данные по ID.
Например, запрос может выглядеть так:
Код:
query {
order(id: "1255") {
id
total
customer {
id
email
}
}
}
order(id) не проверяет владельца, пользователь получает чужой заказ. Если резолвер для customer тоже не проверяет доступ - сверху прилетает ещё и чужой email. И всё это в одном запросе, без нескольких отдельных endpoint’ов.В GraphQL BOLA часто не бросается в глаза, потому что маршрут один - обычно
/graphql. На уровне HTTP всё выглядит одинаково. Разница целиком сидит в теле запроса и в логике резолверов. Поэтому ручное тестирование тут требует чуть больше терпения: надо смотреть, какие поля доступны, как меняются ID в аргументах, какие вложенные объекты можно раскрыть через одну query.Есть ещё одна неприятная деталь. В GraphQL часто используются introspection и schema-aware клиенты. Introspection - это возможность получить описание схемы GraphQL API. Если она доступна, пентестер быстрее видит названия типов, полей и аргументов, где потенциально можно искать object-level access bugs. А дальше уже начинается системный перебор: какие сущности принимают ID, какие возвращают nested-объекты, где нет нормальной проверки контекста.
Где искать в первую очередь
Если отбросить шум, самые жирные BOLA чаще всплывают в трёх местах:| Место | Что обычно ломается |
|---|---|
| nested-маршруты | связь дочернего объекта с владельцем |
| batch-endpoint’ы | проверка прав не на каждый элемент |
| GraphQL-резолверы | непоследовательная авторизация на уровне полей и объектов |
Когда обычные REST-маршруты уже проверены, а дальше начинается вложенная логика, schema traversal и работа с GraphQL-резолверами, полезно посмотреть наш разбор: GraphQL и gRPC API пентест 2026: Уязвимости и защита.
Реальные последствия: как BOLA превращается в цепочку компромисса
С BOLA почти всегда одна и та же ошибка восприятия. Пока уязвимость выглядит как доступ к одному чужому объекту, её легко недооценить. Особенно если речь идёт не о явной админке, а о каком-нибудь профиле, заказе, тикете, файле или настройке. На практике impact определяется не самим фактом чтения чужого объекта, а тем, что ещё разрешено сделать с этим объектом дальше.Если endpoint только читает данные, это уже нарушение разграничения доступа. Но реальный урон обычно начинается там, где объект можно менять, связывать с другими сущностями или использовать как точку опоры для следующего запроса. BOLA хорошо цепляется за бизнес-логику. Именно поэтому такие баги редко заканчиваются на одном скриншоте из Repeater.
Сценарий первый: захват аккаунта через управление профильными объектами
Самый неприятный вариант - когда object-level доступ затрагивает сущности, связанные с идентичностью пользователя. Email, телефон, recovery-контакты, MFA-настройки, список доверенных устройств, параметры SSO, резервные коды. Если хотя бы часть этих объектов доступна через BOLA, дальше уже собирается цепочка до account takeover.Например, API даёт менять email чужого пользователя:
HTTP:
PATCH /api/v1/users/842
Authorization: Bearer <user_token>
Content-Type: application/json
{
"email": "attacker@domain.tld"
}
Фактически - это контроль над каналом восстановления доступа.
Дальше типовая цепочка выглядит так:
| Шаг | Что делает атакующий | Результат |
|---|---|---|
| 1 | меняет email у чужого объекта | подменяет recovery channel |
| 2 | инициирует reset password | ссылка уходит атакующему |
| 3 | задаёт новый пароль | получает полный доступ к аккаунту |
Здесь BOLA уже нельзя оценивать как medium только потому, что она начинается с подмены ID. По факту это прямой путь к захвату учётной записи.
Сценарий второй: массовая эксфильтрация через бизнес-объекты
Вторая частая траектория - не takeover, а сбор данных. Причём не одного объекта, а целого класса сущностей: заказы, счета, обращения, документы, вложения, медицинские записи, экспортные отчёты, payroll-данные, CRM-карточки. Если ID перечисляются или доступны через список, BOLA быстро превращается из локального бага в механизм массовой выгрузки.Обычно это выглядит так:
HTTP:
GET /api/v1/invoices/900144
Authorization: Bearer <user_token>
customer_id, file_id, attachment_url, tenant_id, export_job_id. Один раскрытый объект начинает тянуть за собой другие.Отдельно неприятны endpoint’ы, которые возвращают ссылки на скачивание файлов. Например, invoice сам по себе может выглядеть безобидно, но внутри ответа лежит pre-signed URL.
Pre-signed URL - это временная ссылка на прямой доступ к файлу в объектном хранилище.
Если такая ссылка выдаётся без корректной проверки владельца, BOLA перестаёт быть только про API и уходит уже в прямую утечку документов.
С технической стороны это особенно опасно в двух случаях.
Первый - если API поддерживает batch-операции и отдаёт несколько объектов за один запрос.
Второй - если через один разрешённый endpoint можно получать ID для следующего. Тогда эксфильтрация становится связанной и почти конвейерной.
Сценарий третий: повышение привилегий через административные объекты
Самый тяжёлый вариант - когда через BOLA можно не просто читать или менять чужие данные, а управлять объектами, связанными с более привилегированной ролью. Это уже не горизонтальный доступ, а вертикальный переход.Обычно под удар попадают объекты вроде:
- membership в командах и организациях
- роли пользователей
- API-ключи
- webhook-конфигурации
- billing-настройки
- tenant-параметры
- internal notes и support-инструменты
HTTP:
PUT /api/v1/teams/77/members/842
Authorization: Bearer <user_token>
Content-Type: application/json
{
"role": "owner"
}
В multi-tenant системах - системах, где одна платформа обслуживает несколько изолированных клиентов - такие ошибки особенно опасны. Один неверно защищённый tenant-bound object иногда позволяет выйти за границы своего tenant’а и начать работать с настройками другой организации. Это уже не просто privilege escalation, а фактически cross-tenant compromise.
Почему impact часто оценивают неправильно
В triage BOLA часто недооценивают из-за слишком узкого взгляда на endpoint. Смотрят только на текущий ответ и думают: ну ок, виден чужой объект. Но для нормальной оценки нужно смотреть шире:| Что анализировать | Что это даёт |
|---|---|
| можно ли менять объект | переход от disclosure к takeover |
| есть ли связанные сущности | расширение поверхности атаки |
| используются ли объектом security-sensitive поля | влияние на аутентификацию и роли |
| можно ли пройтись массово по ID | масштаб эксфильтрации |
| находится ли объект в чужом tenant’е | риск cross-tenant impact |
BOLA редко выглядит эффектно в моменте. Но если объект встроен в цепочку аутентификации, биллинга, ролей, файлового доступа или tenant-изоляции, один неверный ownership check превращается в полноценный компромисс. И вот на этом месте разговор уже идёт не о баге в одном endpoint’е, а о том, что модель авторизации в приложении работает непоследовательно.
Защита: как закрывать BOLA системно, а не латать по одному endpoint’у
С BOLA плохо работает подход в духе сейчас добавим ещё одну проверку вот сюда. Иногда это спасает конкретный маршрут, но не решает саму проблему. Если модель авторизации размазана по контроллерам, сервисам и кускам бизнес-логики, баги будут всплывать снова. В одном месте ownership check - проверка, что объект принадлежит текущему пользователю - есть. В соседнем endpoint’е его уже забыли. В третьем он есть, но проверяет не тот объект. И поехало.Надёжная защита начинается с довольно неприятного, но полезного вывода: ID из запроса нельзя считать достаточным контекстом для доступа. Сервер не должен отвечать на вопрос существует ли объект с таким ID. Сервер должен отвечать на другой вопрос: имеет ли текущий субъект право выполнить это действие над этим объектом. Не прочитать объект вообще, а прочитать этот объект. Не обновить профиль как функцию, а обновить свой профиль. В BOLA всё крутится вокруг этой разницы.
Проверка владельца объекта в middleware и сервисном слое
Самая базовая защита - проверять связь между объектом и текущим пользователем до выполнения действия. Не после, не в отдельном валидаторе где-нибудь внизу, а в явной и переиспользуемой точке. Часто это делают в middleware или в сервисном слое.Плохой вариант выглядит примерно так:
JavaScript:
const order = await Order.findByPk(req.params.id);
return res.json(order);
Нормальный вариант уже связывает объект с субъектом из токена:
JavaScript:
const order = await Order.findOne({
where: {
id: req.params.id,
userId: req.user.id
}
});
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
return res.json(order);
404, а в том, что поиск объекта и проверка доступа происходят в одном запросе. Это сильно снижает шанс, что кто-то достанет объект отдельно, а проверку забудет сделать дальше по коду.Но и тут есть нюанс. Такой подход хорош для простых кейсов владелец → объект. Как только в системе появляются команды, роли, tenant’ы, shared-ресурсы и делегированный доступ, ручные проверки начинают плодиться и расходиться по коду. Сначала всё ок. Потом один разработчик проверяет
userId, второй - accountId, третий - membership в организации, четвёртый вообще считает, что gateway уже всё проверил. И в какой-то момент авторизация снова начинает течь.Авторизация на основе политик
Когда логика доступа становится сложнее, лучше уходить от ручных if-ов к policy-based authorization - авторизации на основе политик.Policy-based authorization - это подход, где правило доступа описывается отдельно от бизнес-кода и принимает решение на основе субъекта, действия и объекта.
То есть вопрос формулируется явно:
- кто делает действие
- какое действие он делает
- над каким объектом
user:123 can update order:456 if order.owner_id == user.id.Это можно реализовать через встроенные механизмы фреймворка или вынести в отдельный policy engine - движок принятия решений по правилам доступа.
Из инструментов здесь часто всплывают OPA (Open Policy Agent) и Casbin.
OPA - это policy engine, в котором правила описываются отдельно и применяются к запросам приложения.
Casbin - библиотека для авторизации, поддерживающая role-based и policy-based модели доступа.
Их плюс не в модности, а в том, что они заставляют описывать доступ централизованно. Не каждый endpoint по-своему, а через общую модель. Для BOLA это особенно полезно, потому что уязвимость чаще всего возникает не из-за отсутствия авторизации как таковой, а из-за непоследовательной авторизации.
Если упростить, хороший policy-check звучит примерно так:
текущий пользователь может читать заказ только если он владелец заказа или имеет роль support_agent внутри того же tenant’а.
И это правило должно жить не в одном контроллере, а в одной точке правды.
Защита на уровне базы: row-level security
Когда система становится большой, часть контроля можно и иногда нужно уводить на уровень базы данных. Здесь появляется row-level security - построчная защита данных.Смысл в том, что сама база ограничивает, какие строки доступны текущему контексту. Даже если приложение ошиблось и отправило слишком широкий запрос, база не вернёт чужие записи.
Для BOLA это очень сильный слой защиты, особенно в multi-tenant архитектуре. Если у приложения есть tenant context или user context, можно настроить политику так, чтобы запросы по умолчанию видели только разрешённые строки.
Примерно в такой логике:
SQL:
CREATE POLICY orders_isolation
ON orders
USING (user_id = current_setting('app.user_id')::int);
SELECT * FROM orders WHERE id = 1255, база всё равно применит политику и не отдаст строку, если user_id не совпадает. Но такой подход работает только в том случае, если приложение корректно и безопасно передаёт пользовательский контекст в соединение с базой.Конечно, RLS - row-level security - не заменяет нормальную авторизацию на уровне приложения. Она не знает бизнес-контекста целиком, не всегда удобно ложится на сложные shared-ресурсы и требует аккуратной настройки connection context. Но как страховка от целого класса BOLA-багов это очень мощный слой. Особенно там, где доступ к объектам строится по tenant_id, owner_id или organisation_id.
Что реально работает на практике
Если собрать защиту в цельную модель, она обычно выглядит так:| Уровень | Что должен делать |
|---|---|
| endpoint / middleware | извлекать субъект из токена, а не из client input |
| service / policy layer | принимать решение о доступе к объекту |
| database layer | ограничивать видимость строк через RLS там, где это уместно |
| architecture | не размазывать авторизацию по нескольким сервисам без единой модели |
Поэтому хорошая защита от BOLA - это не набор локальных проверок. Это дисциплина: субъект берётся только из доверенного контекста, доступ к объекту проверяется централизованно, а база не отдаёт больше, чем должна, даже если приложение ошиблось.
Если после разбора BOLA хочется собрать картину шире и посмотреть, как в целом выстраивать тестирование и защиту API, без упора только в один класс уязвимостей, полезно заглянуть в эту статью: Безопасность API: Тестирование и защита интерфейсов.
Примеры в коде: как привязывать авторизацию к объекту, а не к маршруту
BOLA редко выглядит в коде как явная дыра. Обычно это вполне нормальный endpoint, который проходит ревью, отдаёт корректный JSON и не вызывает вопросов, пока кто-то не начинает двигать идентификаторы вручную. В этом и подлость проблемы: баг часто живёт не в отсутствии защиты, а в том, что защита стоит рядом, но не там, где реально принимается решение по объекту.В типичном backend-проекте всё снаружи выглядит прилично. Пользователь аутентифицирован, маршрут закрыт, роль проверена, middleware отработал. После этого код спокойно берёт
id из запроса, достаёт запись из базы и считает задачу выполненной. На уровне функции доступ вроде бы ограничен. На уровне конкретной сущности - уже нет.Поэтому хороший код против BOLA строится не вокруг общей идеи пользователь авторизован. Он строится вокруг более жёсткой логики: объект должен попадать в обработку только в том случае, если текущий субъект действительно имеет право с ним работать. Не после дополнительной проверки где-то ниже. Сразу.
И тут уже многое зависит от стека. Где-то объектную авторизацию удобно встраивать в выборку данных, где-то - в policy-слой, где-то - в queryset или middleware. Ошибка обычно одна и та же, а способы не словить её - разные. Поэтому дальше не теория, а более приземлённые шаблоны для популярных фреймворков.
Spring Security
В Spring Security ошибка чаще всего появляется не в самой аутентификации, а в том, что авторизация проверяется слишком рано. Например, endpoint доступен ролиUSER, и на этом проверка заканчивается. Дальше контроллер получает orderId, достаёт заказ по ID и возвращает его. Формально доступ к функции ограничен. Практически BOLA уже внутри.Плохой вариант обычно выглядит так:
Java:
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return ResponseEntity.ok(order);
}
Нормальная версия уже привязывает выборку к субъекту из security context - контекста безопасности, где Spring хранит данные текущего пользователя:
Java:
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id,
Authentication authentication) {
String username = authentication.getName();
Order order = orderRepository.findByIdAndOwnerUsername(id, username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return ResponseEntity.ok(order);
}
ownerUsername = currentUser перестаёт покрывать всё. В таких случаях логичнее выносить решение в отдельный authorization service и вызывать его явно.Пример через
@PreAuthorize тоже рабочий, если проверка не декоративная, а реально смотрит в объект:
Java:
@PreAuthorize("@orderAccessService.canReadOrder(#id, authentication)")
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return ResponseEntity.ok(order);
}
Express.js
В Express.js BOLA часто появляется из-за простоты самого фреймворка. Он не навязывает строгую модель авторизации. Это удобно, пока проект маленький. Потом логика доступа расползается по middleware, роутам и сервисам, и где-то одна проверка просто не доезжает.Плохой пример обычно выглядит так:
JavaScript:
app.get('/api/orders/:id', async (req, res) => {
const order = await Order.findByPk(req.params.id);
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
Более взрослый подход - не только валидировать токен, но и фильтровать объект сразу по trusted context - доверенному контексту, полученному после проверки токена:
JavaScript:
app.get('/api/orders/:id', async (req, res) => {
const order = await Order.findOne({
where: {
id: req.params.id,
userId: req.user.id
}
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
Поэтому в Express.js полезно выносить проверку в отдельный middleware или policy-layer. Но не общий middleware уровня пользователь авторизован, а конкретный объектный:
JavaScript:
async function canAccessOrder(req, res, next) {
const order = await Order.findOne({
where: {
id: req.params.id,
userId: req.user.id
}
});
if (!order) {
return res.status(404).json({ error: 'Not found' });
}
req.order = order;
next();
}
app.get('/api/orders/:id', canAccessOrder, async (req, res) => {
res.json(req.order);
});
Django
С Django есть отдельный подвох. Многие разработчики рассчитывают, что встроенные permissions уже решают задачу. Но базовые Django permissions в основном отвечают на вопрос может ли пользователь вообще делатьchange, view, delete для модели. А BOLA живёт на уровне конкретной записи.То есть permission
view_order не означает, что пользователь может смотреть любой Order. Это значит, что ему в принципе разрешён такой тип действия. А вот фильтрацию по объекту всё равно нужно делать отдельно.Плохой пример:
Python:
def get_order(request, order_id):
order = Order.objects.get(id=order_id)
return JsonResponse({"id": order.id, "total": order.total})
login_required, проблема никуда не делась.Нормальный подход - сужать queryset до разрешённых объектов:
Python:
from django.http import JsonResponse, Http404
def get_order(request, order_id):
try:
order = Order.objects.get(id=order_id, user=request.user)
except Order.DoesNotExist:
raise Http404("Not found")
return JsonResponse({"id": order.id, "total": order.total})
permission_classes, а переопределять get_queryset() так, чтобы viewset вообще не видел чужие записи:
Python:
from rest_framework.viewsets import ModelViewSet
class OrderViewSet(ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.filter(user=self.request.user)
Но и тут есть ловушка. Как только появляются staff-роли, support-доступ, tenant-границы или nested-ресурсы, простого
filter(user=request.user) уже мало. Тогда нужен либо более умный queryset-builder, либо object permission layer вроде django-guardian, либо вынесенная policy-логика. Иначе код начнёт расползаться в if request.user.is_staff по всем view.Где всё равно ошибаются
Даже при нормальной базе ошибок хватает. Обычно они повторяются в одних и тех же местах:| Ошибка | Что в итоге ломается |
|---|---|
| объект выбирается по ID до проверки доступа | чужая запись уже попала в код |
| проверка доступа живёт только в контроллере | batch, nested и internal endpoints остаются без неё |
берётся user_id из запроса, а не из токена | клиент сам подсовывает контекст |
| permissions проверяют модель, а не объект | доступ получается слишком широким |
Где на самом деле ломается доступ
BOLA неприятна не потому, что её сложно найти. Наоборот. Она часто находится слишком просто. Один изменённый ID, один лишний объект в ответе, одна забытая проверка в PATCH или batch-endpoint’е - и вся аккуратная аутентификация перестаёт что-то значить. В API этого достаточно, чтобы из нормального бизнес-флоу внезапно вылез доступ к чужим данным, чужим действиям и чужим правам. Особенно в системах, где авторизация живёт кусками: чуть-чуть в gateway, чуть-чуть в сервисе, чуть-чуть в ORM, а местами вообще на доверии к клиенту.Хорошая защита от BOLA начинается не с запрета менять ID и не с косметических проверок по пути. Она начинается с более жёсткой дисциплины: объект не должен существовать для запроса сам по себе, пока система не доказала право текущего субъекта работать именно с ним. Если эта логика встроена в выборку, policy-слой и модель доступа целиком, BOLA становится заметно сложнее пропихнуть в прод. Если нет - она всё равно вернётся, просто в другом endpoint’е, в nested-ресурсе, в GraphQL резолвере или в новом микросервисе, который писали уже после первого инцидента.
И вот здесь остаётся вопрос, который для любой команды разработки и API security звучит довольно жёстко: ваша система действительно проверяет доступ к объекту - или просто надеется, что клиент не полезет туда, куда его не звали?
Последнее редактирование: