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 УК РФ).
Проверьте себя
1. Почему BOLA не видна в обычном веб-интерфейсе и проявляется только при прямом обращении к API?
AФронтенд подставляет «правильный» id сам и показывает только свои объекты, но сервер при прямом запросе /api/orders/{id} отдаёт объект по id без проверки владельца
BБраузер шифрует id, а curl и Postman передают его в открытом виде
CИнтерфейс кэширует чужие объекты, а API всегда читает их заново
DBOLA возникает только в GraphQL, а REST-интерфейсы от неё защищены
2. Какой приём делает BOLA невозможной по построению при чтении объекта по id?
AЗаменить автоинкрементные id на случайные UUID, которые нельзя угадать
BОграничить выборку владельцем сразу в запросе: find_one(id=order_id, owner_id=user.id), чтобы чужой объект просто не нашёлся
CОтдавать на чужой объект код 403 Forbidden вместо 404 Not found
DПроверять только то, что пользователь залогинен, на входе в API