BOLA: нарушение авторизации на уровне объекта
Учимся видеть, как API отдаёт чужие данные по одному только id в URL, и закрывать это серверной проверкой владения — на своём стенде, чтобы грамотно защищать продакшен.
BOLA (Broken Object Level Authorization) — уязвимость, при которой API проверяет, что пользователь вошёл, но не проверяет, что он имеет право именно на этот объект; зная или подобрав идентификатор, можно прочитать или изменить чужие данные.
Этот урок — разбор принципа на учебном стенде (OWASP crAPI, juice-shop или собственное демо-API в локальной ВМ). Мы не трогаем чужие сервисы: BOLA изучают, чтобы научиться находить и закрывать её в своём коде. Перебор чужих заказов в реальном магазине — это статья 272 УК РФ, и «мне было любопытно» этого не оправдывает. Тренируемся только там, где разрешено владельцем.
Зачем это знать защитнику
BOLA (исторически её называют IDOR — Insecure Direct Object Reference) много лет стоит первым номером в OWASP API Security Top 10. Причина в её природе: ошибку невозможно «увидеть» в интерфейсе, она прячется в логике сервера. Фронтенд показывает пользователю только его заказы, но сам API при запросе /api/orders/1043 часто отдаёт заказ с этим номером кому угодно, кто залогинен. Разработчик, который понимает механику BOLA, на каждый запрос объекта задаёт себе вопрос «а этот пользователь вообще владеет этим объектом?» — и тогда уязвимость не появляется в принципе.
Как возникает уязвимость: id из запроса без проверки владельца
Корень один: сервер берёт идентификатор прямо из запроса и идёт за объектом в базу, не сверяя его с тем, кто запрос сделал. Аутентификация (кто ты) есть, а авторизации на уровне объекта (твоё ли это) нет. Классический уязвимый обработчик выглядит так:
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user = Depends(current_user)):
# есть проверка "залогинен ли" — current_user не пустой.
# НЕТ проверки "а заказ-то его?" — берём по id из URL.
order = db.orders.find_by_id(order_id)
return order # вернёт ЛЮБОЙ заказ, даже чужой
Атакующий-исследователь на стенде логинится под своей учёткой, открывает свой заказ /api/orders/1043, затем меняет число на 1042, 1041 — и получает заказы других людей с адресами и суммами. Если идентификаторы последовательные (автоинкремент), их даже подбирать не нужно: достаточно вычесть единицу. То же касается методов PUT, PATCH, DELETE — без проверки владельца можно изменить или удалить чужой объект.
Как это работает под капотом
Браузерный фронтенд создаёт иллюзию безопасности: он рисует кнопку «Мой заказ» и подставляет нужный id сам. Но API — это публичный контракт, и клиентом может быть curl, Postman или скрипт. Любой параметр, который приходит от клиента (path, query, тело запроса, заголовок), считается недоверенным. Сервер не должен предполагать, что раз пользователь увидел id в своём интерфейсе, то этот id точно его. Решение об авторизации обязано приниматься на сервере и на каждый объект, а не один раз на входе в API.
Опасна и попытка «спрятать» идентификаторы, заменив автоинкремент на UUID. Случайный UUID труднее угадать, и это полезно как defense-in-depth — но это не авторизация, а лишь усложнение перебора. Если UUID утечёт (в логах, в ссылке, в ответе соседнего эндпоинта), доступ снова открыт. Сокрытие id маскирует проблему, но не закрывает её.
Как защититься
1. Привязывайте запрос к владельцу прямо в выборке. Самый надёжный приём — спрашивать у базы не «дай объект по id», а «дай объект по id, который принадлежит этому пользователю». Тогда чужой объект просто не найдётся, и BOLA становится невозможной по построению:
@app.get("/api/orders/{order_id}")
def get_order(order_id: int, user = Depends(current_user)):
# выборка СРАЗУ ограничена владельцем
order = db.orders.find_one(id=order_id, owner_id=user.id)
if order is None:
# для чужого/несуществующего — одинаковый ответ
raise HTTPException(status_code=404, detail="Not found")
return order
2. Проверяйте право явно, если связь сложнее. Когда доступ зависит от роли или членства (например, врач видит карты только своих пациентов), вынесите решение в одну функцию-«привратник» и зовите её в каждом обработчике:
def authorize(user, resource):
if resource.owner_id == user.id:
return
if user.role == "admin":
return
raise HTTPException(status_code=403, detail="Forbidden")
@app.delete("/api/orders/{order_id}")
def delete_order(order_id: int, user = Depends(current_user)):
order = db.orders.find_by_id(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Not found")
authorize(user, order) # <-- решение об авторизации на сервере
db.orders.delete(order_id)
return {"status": "deleted"}
3. Не раскрывайте существование объекта. На чужой объект отвечайте 404 Not found, а не 403 Forbidden, чтобы по коду ответа нельзя было понять, существует ли заказ с таким номером. Это лишает атакующего обратной связи для перечисления.
4. Обнаружение. Логируйте каждый отказ авторизации с id пользователя и id объекта. Резкий рост 404/403 с последовательными идентификаторами от одной учётной записи — характерный признак попытки перебора BOLA. Добавьте сюда rate limiting (об этом — отдельный урок), чтобы перебор стал дорогим.
Итоги
- BOLA/IDOR — это аутентификация без авторизации на уровне объекта: «вошёл» проверили, «твоё ли это» — нет.
- Любой id из запроса (path, query, тело) недоверенный; решение о доступе принимается на сервере и на каждый объект, а не один раз на входе.
- Самая надёжная защита — ограничивать выборку владельцем (
find_one(id=..., owner_id=user.id)), а где сложнее — звать единыйauthorize(). - UUID вместо автоинкремента усложняет перебор, но не заменяет проверку прав.
- Отвечайте
404на чужие объекты, логируйте отказы, ставьте лимиты. Практика — только на стенде и своих системах (ст. 272 УК РФ).