Статья BOLA (Broken Object Level Authorization): Практические эксплойты и защита API (OWASP API Top 10)

1772908018805.webp

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>
Если изменить ID:
HTTP:
GET /api/v1/orders/1255
и API всё равно отдаёт данные - доступ к объекту никак не проверяется.

На практике последствия могут быть серьёзнее, чем кажется. Через 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;
На уровне архитектуры это небольшая деталь. На уровне безопасности - полноценная уязвимость. Часто BOLA появляется в API, где разработчики ориентируются на модель CRUD. Endpoint создаётся для операций с ресурсами: получить объект, изменить объект, удалить объект. ID ресурса передаётся в URL, сервер достаёт запись из базы и возвращает результат. Пока приложение используется корректно, всё выглядит нормально. Но если клиент начнёт менять идентификаторы объектов, логика начинает ломаться.

Горизонтальный и вертикальный доступ​

В практике тестирования 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
Gateway подтверждает, что пользователь авторизован. Orders Service получает order_id и извлекает запись из базы. Если проверка владельца не реализована внутри сервиса - BOLA появляется автоматически. В сложных системах это может происходить даже на внутренних API. Один сервис запрашивает данные у другого, предполагая, что доступ уже проверен на предыдущем уровне.
1772911659838.webp


Эксплуатация 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>
Меняем ID:
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"
}
Если API принимает запрос и обновляет чужой профиль - это уже не просто утечка данных. Это прямое изменение чужого объекта. А там дальше быстро собирается цепочка до захвата аккаунта, особенно если можно менять почту, телефон, MFA-параметры или recovery-данные.

Отдельный момент - где именно лежит идентификатор. Не всегда это сегмент 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’ы написаны разными командами.
Хорошая практика при эксплуатации - сравнивать не только статус, но и:
Что сравниватьЗачем
тело ответаесть ли чужие данные
размер ответаскрытые различия между объектами
время ответакосвенное подтверждение существования объекта
побочные эффектыизменился ли ресурс после запроса
Иногда API не возвращает данные прямо, но даёт понять, что объект существует. Например, по разному времени ответа, по ошибке валидации, по наличию связанных сущностей. Это уже не всегда полноценная эксплуатация, но хороший зацеп для дальнейшей проверки.
1772911684930.webp


Продвинутая эксплуатация: где 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
Здесь уже три уровня объектов, и на любом из них может отсутствовать связанная проверка. Сервер может убедиться, что пользователь видит указанную компанию, но не проверить, относится ли запрошенный invoice к этому пользователю внутри неё. Чем глубже вложенность, тем выше шанс, что одна из проверок где-то выпала.

Пакетные endpoint’ы​

Пакетные операции - отдельная боль. API получает массив объектов или массив ID и обрабатывает их одной операцией. Такие endpoint’ы удобны для фронтенда и мобильных клиентов, но с точки зрения авторизации это постоянная зона риска.
Например:
HTTP:
POST /api/v1/orders/batch
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "ids": [1254, 1255, 1256]
}
Если сервер проверяет доступ только к первому объекту или вообще обрабатывает массив без построчной авторизации, можно получить пачку чужих данных за один запрос. Иногда ситуация ещё хуже: часть объектов возвращается корректно, часть - чужая, а API спокойно упаковывает всё в один JSON-ответ. В логах и мониторинге это выглядит как обычный batch-read.
С пакетным обновлением проблема ещё жёстче:
HTTP:
PATCH /api/v1/users/batch
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "items": [
    {"id": 842, "role": "user"},
    {"id": 843, "role": "admin"}
  ]
}
Если валидация прав выполняется до разбора массива, а не для каждого элемента отдельно, одна ошибка превращается в массовую запись по чужим объектам. Такой баг уже не про один чужой профиль. Это прямой путь к privilege escalation - повышению привилегий - или к массовой порче данных.

BOLA в GraphQL​

1772911726362.webp

С 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-резолверынепоследовательная авторизация на уровне полей и объектов
И вот здесь уже видно главное. BOLA - это редко один дефект в одном endpoint’е. Чаще это системная проблема авторизационной модели. Где-то права проверяются на маршруте, где-то в сервисе, где-то в ORM-запросе, где-то вообще нигде. Пока всё идёт по ожидаемому сценарию, приложение живёт спокойно. Стоит начать менять объектный контекст - и появляются дыры.

Когда обычные 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задаёт новый парольполучает полный доступ к аккаунту
И это ещё мягкий сценарий. Если API отдельно управляет MFA или trusted devices, можно обойтись даже без сброса пароля. Иногда хватает изменить номер телефона для OTP, иногда - отключить фактор через endpoint настроек безопасности, если тот тоже не проверяет владельца объекта.
Здесь BOLA уже нельзя оценивать как medium только потому, что она начинается с подмены ID. По факту это прямой путь к захвату учётной записи.

Сценарий второй: массовая эксфильтрация через бизнес-объекты​

Вторая частая траектория - не takeover, а сбор данных. Причём не одного объекта, а целого класса сущностей: заказы, счета, обращения, документы, вложения, медицинские записи, экспортные отчёты, payroll-данные, CRM-карточки. Если ID перечисляются или доступны через список, BOLA быстро превращается из локального бага в механизм массовой выгрузки.
Обычно это выглядит так:
HTTP:
GET /api/v1/invoices/900144
Authorization: Bearer <user_token>
Если endpoint стабильно возвращает чужие объекты, а идентификаторы можно перебирать или получать из смежных API, начинается уже не тестовая проверка, а полноценная эксфильтрация. И здесь важен не только сам JSON-ответ. Очень часто внутри объекта лежат ссылки на связанные сущности: 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"
}
Если сервер проверяет только то, что пользователь состоит в команде, но не проверяет право менять именно этот membership-object, обычный пользователь может повысить роль себе или другому аккаунту. И вот здесь BOLA уже начинает пересекаться с broken function level authorization - ошибкой разграничения доступа на уровне функций. Только входная точка всё равно остаётся объектной: атакующий меняет не тот объект, к которому должен иметь доступ.

В multi-tenant системах - системах, где одна платформа обслуживает несколько изолированных клиентов - такие ошибки особенно опасны. Один неверно защищённый tenant-bound object иногда позволяет выйти за границы своего tenant’а и начать работать с настройками другой организации. Это уже не просто privilege escalation, а фактически cross-tenant compromise.

Почему impact часто оценивают неправильно​

В triage BOLA часто недооценивают из-за слишком узкого взгляда на endpoint. Смотрят только на текущий ответ и думают: ну ок, виден чужой объект. Но для нормальной оценки нужно смотреть шире:
Что анализироватьЧто это даёт
можно ли менять объектпереход от disclosure к takeover
есть ли связанные сущностирасширение поверхности атаки
используются ли объектом security-sensitive полявлияние на аутентификацию и роли
можно ли пройтись массово по IDмасштаб эксфильтрации
находится ли объект в чужом tenant’ериск cross-tenant impact
То есть вопрос всегда один и тот же: что дальше можно сделать с этим объектом. Не что он возвращает сейчас, а какие процессы в приложении от него зависят. В зрелых приложениях именно это и определяет реальный severity.

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);
Здесь сервер просто получает объект по ID и отдаёт его. Никакой проверки владельца нет.
Нормальный вариант уже связывает объект с субъектом из токена:
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 нельзя стабильно закрыть только код-ревью или только тестированием. Если в архитектуре нет единого места, где принимается решение о доступе к объекту, ошибки будут повторяться. Один endpoint забудут. Один batch-обработчик напишут мимо общей логики. Один GraphQL резолвер вытащит объект напрямую. И всё начнётся заново.

Поэтому хорошая защита от 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);
}
Это уже лучше, но не всегда достаточно. Как только появляется делегированный доступ, support-роль, tenant-изоляция или shared-объекты, условие 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);
});
Тут endpoint защищён ровно ничем, кроме надежды, что клиент будет вести себя прилично.
Более взрослый подход - не только валидировать токен, но и фильтровать объект сразу по 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);
});
Если логика сложнее, лучше не тащить её прямо в маршрут. Иначе через десять endpoint’ов начнётся копипаст с микроскопическими расхождениями. А именно из таких расхождений BOLA и вылезает.
Поэтому в 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);
});
Плюс такого шаблона в том, что объект уже проверен и прокинут дальше. Минус - если начать плодить по middleware на каждый тип сущности без общей модели, проект быстро зарастёт ручной логикой. Поэтому для систем посложнее тут уже полезны Casbin или собственный policy-service, а не просто набор проверок по папкам.

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})
Даже если этот view завёрнут в 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})
Если используется Django REST Framework, там особенно важно не ограничиваться 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)
Это один из самых адекватных способов гасить BOLA в DRF, потому что он режет проблему у входа. Не после получения объекта, а ещё на стадии базовой выборки.
Но и тут есть ловушка. Как только появляются 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 проверяют модель, а не объектдоступ получается слишком широким
И вот тут видно главное различие между просто защищённым endpoint’ом и реально устойчивой моделью. В хорошем коде доступ к объекту не является отдельной дополнительной проверкой. Он встроен в сам способ выборки, обновления и удаления. То есть приложение не сначала находит объект, а потом думает, можно ли с ним работать. Оно изначально строит запрос так, чтобы чужой объект просто не попал в область видимости.

Где на самом деле ломается доступ​

BOLA неприятна не потому, что её сложно найти. Наоборот. Она часто находится слишком просто. Один изменённый ID, один лишний объект в ответе, одна забытая проверка в PATCH или batch-endpoint’е - и вся аккуратная аутентификация перестаёт что-то значить. В API этого достаточно, чтобы из нормального бизнес-флоу внезапно вылез доступ к чужим данным, чужим действиям и чужим правам. Особенно в системах, где авторизация живёт кусками: чуть-чуть в gateway, чуть-чуть в сервисе, чуть-чуть в ORM, а местами вообще на доверии к клиенту.

Хорошая защита от BOLA начинается не с запрета менять ID и не с косметических проверок по пути. Она начинается с более жёсткой дисциплины: объект не должен существовать для запроса сам по себе, пока система не доказала право текущего субъекта работать именно с ним. Если эта логика встроена в выборку, policy-слой и модель доступа целиком, BOLA становится заметно сложнее пропихнуть в прод. Если нет - она всё равно вернётся, просто в другом endpoint’е, в nested-ресурсе, в GraphQL резолвере или в новом микросервисе, который писали уже после первого инцидента.

И вот здесь остаётся вопрос, который для любой команды разработки и API security звучит довольно жёстко: ваша система действительно проверяет доступ к объекту - или просто надеется, что клиент не полезет туда, куда его не звали?
 
Последнее редактирование:
  • Нравится
Реакции: Edmon Dantes
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab