IDOR и нарушение контроля доступа

Разбираем, как приложение случайно отдаёт чужие данные по id, и закрываем это серверной проверкой прав — на учебном стенде.

IDOR (Insecure Direct Object Reference) — уязвимость, при которой сервер отдаёт объект по идентификатору из запроса, не проверив, что текущий пользователь имеет право на этот объект.

IDOR — частный, очень распространённый случай категории Broken Access Control, которая возглавляет OWASP Top 10. Практикуемся на стендах с ролями и несколькими аккаунтами (OWASP Juice Shop, лабораторные ВМ). Просматривать чужие заказы или данные на реальном сервисе, перебирая id, — это несанкционированный доступ (ст. 272 УК РФ), даже если «просто из любопытства» и «ничего не менял».

Зачем это знать защитнику

IDOR коварен тем, что приложение работает правильно с точки зрения функциональности — оно просто забывает спросить «а можно ли этому пользователю?». Такую дыру не видно в обычном тестировании по сценарию: нужен взгляд защитника, который проверяет каждый доступ к объекту. Разработчик, понимающий IDOR, ставит проверку прав там, где её легко пропустить.

Как возникает уязвимость

Типичный эндпоинт принимает id объекта и достаёт его из базы — но проверяет только аутентификацию (пользователь вошёл), забывая про авторизацию (этот объект принадлежит ему):

# УЯЗВИМО: достаём по id, не проверив владельца
@app.get("/api/orders/<int:order_id>")
@login_required
def get_order(order_id):
    order = Order.query.get(order_id)   # любой order_id вернёт любой заказ
    return order.to_json()

Пользователь видит в адресе свой заказ /api/orders/1043. Если поменять номер на 1042, сервер послушно вернёт чужой заказ — потому что в коде нет ни одной строки, сверяющей владельца. Аутентификация пройдена (человек залогинен), но авторизация отсутствует.

Почему это происходит так часто

Идентификаторы предсказуемы: последовательные числовые id сами подсказывают «соседние» объекты. Но дело не в самих числах — заменить id на случайный UUID помогает мало (это лишь усложняет угадывание, не закрывает дыру). Настоящая причина — проверка прав вынесена из логики или забыта. Любой ссылающийся на объект параметр (id в пути, в теле, в скрытом поле формы) контролируется клиентом и не может служить доказательством прав.

Как это находят на CTF

На стенде заводят два аккаунта. Под аккаунтом A открывают свой ресурс, фиксируют его id, затем под тем же аккаунтом подставляют id ресурса аккаунта B и смотрят отклик. Вернулись чужие данные (а не 403 Forbidden) — IDOR подтверждён. Перебор соседних id автоматизируют, но смысл проверки — найти эндпоинт без авторизации, чтобы добавить её.

Как это работает под капотом

Сервер доверяет идентификатору из запроса как «адресу» нужной строки и выполняет выборку WHERE id = ?. Сама по себе выборка корректна — ошибка в том, что между «нашли объект» и «отдали объект» нет шага «а имеет ли право спрашивающий». Контроль доступа — это серверное решение: клиент по определению не заслуживает доверия, ведь он может прислать любой id. Поэтому решение всегда принимается на сервере, на основе личности из проверенной сессии, а не из параметров запроса.

Как защититься

1. Привязывайте выборку к владельцу. Самый надёжный приём — запрашивать объект сразу в связке с текущим пользователем, чтобы чужой объект просто не находился:

# БЕЗОПАСНО: выбираем только среди заказов текущего пользователя
@app.get("/api/orders/<int:order_id>")
@login_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id, user_id=current_user.id
    ).first()
    if order is None:
        abort(404)        # чужой или несуществующий — неотличимо
    return order.to_json()

Чужой id теперь не пройдёт фильтр user_id=current_user.id и вернёт 404 — данные не утекают.

2. Явная проверка прав, если связка по владельцу не подходит. Для ролей и совместного доступа проверяйте право отдельно, до выдачи объекта:

# БЕЗОПАСНО: централизованная проверка доступа
order = Order.query.get_or_404(order_id)
if not can_access(current_user, order):   # роль, владелец, шаринг
    abort(403)
return order.to_json()

3. Проверяйте на сервере и для каждого действия. Скрытые поля, выпадающие списки и «неизменяемые» id на стороне клиента ничего не гарантируют — их легко подменить. Авторизуйте и чтение, и запись, и удаление. Не полагайтесь на то, что «ссылки на чужие объекты не видно в интерфейсе»: безопасность через сокрытие (security by obscurity) не работает.

4. Принцип «запрещено по умолчанию» и обнаружение. Доступ закрыт, пока явно не разрешён. Логируйте отказы (всплеск 403/404 по разным id с одного аккаунта — признак перебора) и тестируйте контроль доступа: автотест, который под одним пользователем дёргает объекты другого и ждёт 403/404, ловит регрессии раньше злоумышленника.

Где должна жить проверка доступа

Частая причина IDOR — проверка прав «расползается» по контроллерам, и в каком-то новом эндпоинте её просто забывают добавить. Надёжнее централизовать решение: единая функция или слой авторизации (политики, декораторы, middleware), через которые проходит каждый доступ к объекту. Тогда новый разработчик не сможет случайно отдать объект без проверки — она встроена в общий путь. Особенно внимательно относитесь к «массовым» эндпоинтам и фильтрам: запрос вида «верни все заказы» тоже должен ограничиваться текущим пользователем на уровне выборки из базы, иначе утечёт весь список разом.

Проверка в лаборатории

На стенде заведите два аккаунта и пройдите по списку эндпоинтов: под пользователем A попробуйте прочитать, изменить и удалить объекты пользователя B по их id. Ожидаемый результат для всех трёх действий — 403 или 404, а не чужие данные и не успешное изменение. Не забудьте про операции записи: бывает, что чтение защищено, а PUT/DELETE по тому же id — нет. Зафиксируйте этот сценарий как регрессионный тест: контроль доступа легко сломать рефакторингом, и автоматическая проверка — лучший способ это заметить.

Итоги

  • IDOR — это нарушение контроля доступа: сервер отдаёт объект по id, не проверив права спрашивающего.
  • Корень — путаница аутентификации (вошёл) и авторизации (имеет право); проверка прав забыта.
  • Защита — привязка выборки к владельцу либо явная серверная проверка прав перед выдачей.
  • Случайные UUID и сокрытие ссылок не заменяют авторизацию; проверяйте на сервере каждое действие.
  • Перебор чужих id на реальном сервисе — несанкционированный доступ (ст. 272 УК РФ). Только стенд.
Проверьте себя
1. В чём корневая причина IDOR?
AИдентификаторы объектов числовые и идут по порядку
BСервер проверил аутентификацию (пользователь вошёл), но не проверил авторизацию (имеет ли он право на этот объект)
CДанные в базе хранятся без шифрования
DКлиент видит id объекта в адресной строке
2. Какой подход надёжно закрывает IDOR в эндпоинте «получить заказ по id»?
AЗаменить числовой id на случайный UUID и больше ничего не менять
BСкрыть ссылку на заказ из интерфейса
CВыбирать заказ в связке с текущим пользователем (filter_by(id=..., user_id=current_user.id)) либо явно проверять права перед выдачей
DДобавить капчу на страницу заказа