Почему зайти в систему - еще не значит получить право на все данные.

IDOR, или небезопасная прямая ссылка на объект, появляется в момент, когда приложение получает от клиента ссылку на запись, файл, заказ, профиль или другой объект и слишком охотно верит присланному значению. Пользователь меняет идентификатор, сервер берёт новый параметр как есть и возвращает чужие данные либо разрешает чужое действие. OWASP описывает IDOR как частный случай нарушения контроля доступа, а в мире API для близкой проблемы обычно используют термин BOLA, то есть нарушение авторизации на уровне объекта.
Уязвимость давно не ограничивается сценарием «подменили цифру в адресной строке». В реальных сервисах опасные ссылки на объект живут в JSON, скрытых полях формы, заголовках, путях к файлам, параметрах массовых операций и внутренних вызовах API. Поэтому разговор про IDOR на самом деле всегда сводится к одному вопросу: проверяет ли сервер право текущего пользователя на конкретный объект, к которому идёт обращение.
Проблема неприятна не только для ИБ-команды, но и для продукта. Один пропущенный фильтр по владельцу записи может открыть доступ к чужим заказам, документам, адресам, счетам, обращениям в поддержку, экспортам и вложениям. Если платформа работает в B2B-модели или хранит персональные данные, последствия быстро выходят за пределы одного бага и превращаются в инцидент с расследованием, уведомлениями и тяжёлыми разговорами с клиентами.
С технической точки зрения IDOR возникает там, где бизнес-объект доступен по прямому указателю. Таким указателем может быть номер заказа, идентификатор пользователя, имя файла, ключ документа, номер тикета или внешний идентификатор договора. Разработчик получает параметр из запроса, ищет объект в базе или хранилище и отдаёт результат. Если в цепочке нет проверки «имеет ли текущий пользователь право работать именно с этим объектом», приложение превращает пользовательский ввод в универсальный пропуск.
Здесь часто путают аутентификацию и авторизацию. Аутентификация отвечает на вопрос «кто вошёл в систему». Авторизация отвечает на вопрос «что разрешено этому пользователю». Для IDOR характерна ситуация, когда вход в систему организован корректно, сеанс живой, токен валидный, а доступ к конкретному объекту никто не перепроверил. Пользователь уже внутри, но границы видимости данных размечены криво или вовсе отсутствуют.
В терминологии OWASP для API похожую проблему выносят в отдельную категорию Broken Object Level Authorization. Формулировка полезна тем, что убирает лишнюю привязку к URL и напоминает о сути проблемы. Речь не о способе передачи идентификатора, а о провале авторизации на уровне каждой записи, файла, сущности или ресурса.
Базовый сценарий выглядит достаточно просто. Пользователь открывает собственный заказ по адресу /orders/18452, меняет число на 18453 и получает чужую карточку. В более современном интерфейсе число может исчезнуть из URL, но появиться в JSON вроде {"orderId":"18453"} или в запросе GET /api/orders/18453. Сервер всё равно остаётся уязвимым, если обработчик выбирает запись только по идентификатору и не связывает её с владельцем, ролью, проектом или организацией.
Добавляет проблем и ложное чувство безопасности. Команда заменяет последовательные числа на UUID, а потом выдыхает с облегчением. Случайный идентификатор действительно усложняет перебор, но не исправляет саму ошибку. Если чужой UUID утёк через письмо, журнал, фронтенд, историю операций или экспорт, отсутствие проверки прав снова превращает «сложный» идентификатор в ключ от соседней двери.
Искать IDOR только в адресной строке уже поздно. Намного полезнее просмотреть все точки, где клиент указывает, к какому объекту хочет обратиться. Веб-формы могут отправлять скрытые поля, одностраничные интерфейсы передают JSON с идентификаторами, мобильные приложения держат нужные значения в теле запроса и заголовках, а файловые сервисы принимают имена вложений и относительные пути. Если сервер работает по принципу «какой объект попросили, тот и отдали», риск уже есть.
Особенно опасны массовые и фоновые операции. Экспорт отчётов, пакетное удаление, изменение статусов, повторная отправка документов, выдача ссылок на скачивание и синхронизация между сервисами часто обрастают обходными маршрутами, где строгая авторизация почему-то считается лишней роскошью. Отдельная зона риска связана с многопользовательскими платформами, где один и тот же сотрудник может состоять в нескольких организациях, командах или проектах. В такой среде мало знать роль пользователя. Нужно учитывать и границы конкретного арендатора, проекта или пространства данных.
Наконец, стоит внимательно смотреть на файловые операции. Когда приложение выдаёт документы по имени файла, относительному пути или внешнему ключу, ошибка контроля доступа легко соседствует с обходом каталога и другими неприятными эффектами. Тогда проблема выходит за пределы карточек из базы и затрагивает содержимое файловой системы, резервные копии, внутренние шаблоны и служебные выгрузки.
Главная сложность в том, что хороший сканер не заменяет понимание бизнес-логики. Нужно знать, какие объекты принадлежат разным пользователям, какие роли существуют в системе, где проходят границы между организациями и какие действия доступны для каждой роли. Поэтому лучше всего IDOR ловится ручной проверкой с двумя или несколькими аккаунтами, у которых разный набор данных и разные права.
Сначала стоит собрать все запросы, где фигурируют идентификаторы объектов. Затем те же запросы повторяют от имени другого пользователя, меняя значения в пути, параметрах, теле запроса, скрытых полях и заголовках. Если сервер отдаёт чужие данные или позволяет провести чужую операцию, баг найден. Если сервер возвращает корректный отказ, проверку продолжают на соседних ручках и связанных сущностях, потому что одна закрытая точка входа ещё ничего не гарантирует.
Особое внимание стоит уделять не только чтению, но и изменению состояния. Проверять нужно просмотр, редактирование, удаление, публикацию, экспорт, загрузку вложений, повторную отправку, массовые операции и все действия, которые меняют жизненный цикл объекта. Многие команды закрывают очевидный сценарий чтения и забывают, что тот же идентификатор участвует в обновлении, архивации или удалении.
Подозрение вызывают последовательные идентификаторы, ответы сервера с разным поведением для существующего и несуществующего объекта, приём полей userId, accountId или tenantId прямо от клиента и запросы к базе, которые ищут запись только по первичному ключу. Если система знает текущего пользователя из сеанса, но всё равно просит клиента явно сообщить, кому принадлежит объект, архитектура уже просит неприятностей.
Если платформа поддерживает несколько организаций, пространств данных или ролей, проверка должна обязательно включать переход через такие границы. Пользователь из компании А не должен видеть объект компании Б, даже если обе компании существуют в одной базе и используют одинаковые маршруты API. Именно в многопользовательских системах пропущенный фильтр по арендатору чаще всего даёт самый дорогой инцидент.
Первый ущерб почти всегда связан с раскрытием данных. Пользователь видит чужие заказы, обращения, переписку, адреса доставки, персональные сведения, медицинские документы или внутренние материалы компании. В некоторых продуктах одна такая ошибка уже означает полноценную утечку. Для публичной платформы к техническим последствиям быстро добавляется репутационный удар. Клиенту обычно всё равно, был ли доступ получен через «сложный вектор» или через банальную подмену идентификатора. Для клиента утечка остаётся утечкой.
Второй класс рисков связан не с чтением, а с изменением. Если приложение позволяет через тот же объектный идентификатор обновлять, удалять, публиковать, утверждать или отклонять записи, атакующий получает инструмент для порчи данных и бизнес-процессов. Можно переписать адрес доставки, удалить вложение, изменить статус обращения, закрыть заявку или подменить содержание документа. Нарушение целостности в ряде случаев бьёт по компании сильнее, чем простой просмотр чужой карточки.
Третий риск связан с масштабом. Хорошо автоматизируемый IDOR быстро превращается в массовый сбор данных. Злоумышленнику не нужно сидеть в браузере и вручную менять цифры. Достаточно наладить перебор, пакетную отправку запросов и сортировку ответов по кодам и структуре.
Разговор об IDOR часто остаётся слишком абстрактным, поэтому полезно разложить один запрос по слоям приложения. Логика безопасной обработки всегда строится вокруг одной идеи: сначала определить, кто пришёл, затем понять, какие объекты ему вообще доступны, и только после этого искать конкретную запись. Если приложение сначала находит объект по идентификатору, а вопрос доступа откладывает «на потом», риск резко растёт.
Ниже простая схема, которая показывает разницу между уязвимой и безопасной обработкой. Схема намеренно упрощена, зато хорошо видна точка, где команды обычно теряют контроль.
| Шаг | Уязвимая цепочка | Безопасная цепочка |
|---|---|---|
| 1 |
Клиент присылает /orders/18453
|
Клиент присылает /orders/18453
|
| 2 |
Контроллер берёт 18453 как есть
|
Контроллер определяет текущего пользователя и его контекст |
| 3 |
Сервис ищет заказ по id = 18453
|
Сервис ограничивает выборку заказами текущего пользователя или организации |
| 4 | База находит запись и возвращает её |
База ищет запись по id = 18453 внутри разрешённого набора
|
| 5 | Пользователь получает чужой заказ | Пользователь получает только свой заказ или отказ в доступе |
Смысл схемы в одном простом правиле. Проверка доступа не должна быть декоративной надстройкой после выборки. Проверка доступа должна определять саму выборку. Такой подход и проще для чтения кода, и надёжнее в эксплуатации, потому что случайно забыть фильтр уже заметно труднее.
Самая надёжная защита выглядит скучно и поэтому работает. Сервер должен проверять доступ к каждому объекту в каждой операции, где клиент ссылается на идентификатор. Не на уровне видимости кнопок, не в логике фронтенда и не в надежде на «доверенный мобильный клиент», а в серверном коде, который принимает решение о чтении, изменении и удалении. По OWASP объект нужно искать только в наборе ресурсов, доступных текущему пользователю.
Ниже пример на Python с Flask и SQLAlchemy. Первый обработчик уязвим, потому что выбирает заказ только по идентификатору. Второй делает то же действие безопасно, потому что сначала ограничивает выборку текущим пользователем.
# Плохо: сервер верит присланному идентификатору
@app.get("/orders/<int:order_id>")
@login_required
def get_order(order_id):
order = Order.query.filter_by(id=order_id).first_or_404()
return {"id": order.id, "amount": order.amount, "owner": order.user_id}
# Хорошо: сервер ищет объект только среди заказов текущего пользователя
@app.get("/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 {"id": order.id, "amount": order.amount}
Тот же принцип работает и для многоарендных систем, где доступа «по владельцу пользователя» уже мало. Тогда в фильтр добавляют организацию, проект или другую границу данных. Важно, чтобы такие ограничения не зависели от полей, которые прислал клиент. Идентификатор арендатора, роль доступа и связь пользователя с проектом сервер должен брать из доверенного контекста, а не из тела запроса.
# Лучше для B2B-платформы: учитываем и пользователя, и организацию
@app.patch("/projects/<uuid:project_id>/documents/<uuid:doc_id>")
@login_required
def update_document(project_id, doc_id):
membership = Membership.query.filter_by(
user_id=current_user.id,
project_id=project_id,
can_edit=True
).first()
if membership is None:
abort(403)
document = (
Document.query
.filter_by(id=doc_id, project_id=project_id)
.first_or_404()
)
document.title = request.json["title"]
db.session.commit()
return {"status": "ok"}
Такие примеры кажутся очевидными, пока не вспоминаешь, сколько кода в реальных продуктах живёт по инерции «сначала найдём объект, потом разберёмся». На практике полезно вынести проверку в единый слой политики доступа или в сервисный метод, чтобы разработчики не писали каждый раз свою версию авторизации. Чем меньше самодеятельности вокруг прав, тем меньше вероятность, что одна ручка останется без проверки.
Первая и главная мера уже понятна: строить выборку данных через контекст доступа. Вместо «найди документ по идентификатору» нужно писать «найди документ по идентификатору среди документов, доступных текущему пользователю». Такой подход одинаково полезен для чтения, изменения и удаления. Для файлов действует та же логика. Лучше хранить связь «файл – владелец – разрешённые роли» и проверять её перед выдачей содержимого, чем раздавать документы по имени или пути.
Вторая мера связана с сокращением площади атаки. Серверу не стоит принимать от клиента поля вроде ownerId, tenantId или role, если эти значения уже можно вывести из сеанса, токена, членства в проекте или серверной конфигурации. Каждое лишнее доверие к клиенту превращает интерфейс запроса в набор рычагов, которыми атакующий может спокойно пользоваться вручную.
Третья мера носит вспомогательный характер, но пренебрегать ею не стоит. Случайные внешние идентификаторы, ограничение скорости, журналирование, оповещения о сериях отказов в доступе и регрессионные негативные тесты заметно осложняют эксплуатацию и помогают быстрее заметить проблему. Но такие шаги остаются вторым контуром защиты. Без корректной проверки прав на объект они не спасают.
Команда находит один уязвимый маршрут, добавляет туда проверку и считает задачу закрытой. Но IDOR редко живёт в одиночку. Если проблема нашлась в чтении заказа, почти наверняка стоит проверить обновление, экспорт, историю изменений, вложения и связанные комментарии. Если дыра обнаружилась в веб-интерфейсе, разумно сразу же смотреть соседние API-методы и мобильные точки входа.
Другая ошибка связана с чрезмерной верой в UUID и прочие непрозрачные идентификаторы. Случайный ключ хорош как дополнительный барьер, но он не делает авторизацию ненужной. Как только чужой идентификатор утечёт через экспорт, письмо, журнал, фронтенд или интеграцию, вся надежда на «неугадаемость» заканчивается. После этого снова остаётся только один вопрос: проверяет ли сервер право на объект.
Третья ошибка появляется на этапе тестирования. Команда проверяет функцию под одной учётной записью и не моделирует чужие роли, другие организации и смежные наборы данных. Для функциональности такого теста может хватить, для IDOR – почти никогда. Без нескольких аккаунтов, разных ролей и хотя бы минимального набора объектов ручная проверка превращается в самоуспокоение.
Начать лучше с короткой инвентаризации. Выделите чувствительные объекты, которые нельзя отдавать или менять без строгой проверки. Обычно туда попадают профили, заказы, счета, документы, тикеты поддержки, экспорт, вложения, черновики, административные операции и массовые действия. Затем для каждого объекта зафиксируйте простую матрицу доступа: кто может читать, кто может менять, кто может удалять, кто может экспортировать.
Следом стоит взять два-три реальных сценария и пройти их руками с разными аккаунтами через прокси. Любой запрос, где есть идентификатор объекта, должен вызывать профессиональную подозрительность. Если система позволяет ссылаться на файл, запись, проект, заказ, организацию или пользователя, проверка права на конкретный объект обязана быть видна в коде. Если проверка не видна, лучше не строить догадок и проверить маршрут сразу.
Наконец, полезно закрепить правило в процессе разработки. Для любой новой функции, которая работает с объектом по идентификатору, команда должна задавать себе один простой вопрос: где именно сервер проверяет право текущего пользователя на этот объект. Вопрос звучит приземлённо, но именно такая приземлённость и спасает от дорогих ошибок.
IDOR кажется простым багом лишь до первого серьёзного инцидента. На деле уязвимость бьёт в самую болезненную часть приложения – в логику доверия и разграничения доступа. Пока сервер безоговорочно принимает пользовательский идентификатор и не связывает объект с владельцем, ролью, проектом или организацией, платформа остаётся открытой для чужих просмотров, изменений и массового сбора данных.
Хорошая новость в том, что защита здесь вполне инженерная и без магии. Нужно проверять доступ на уровне каждого объекта, строить выборку данных через доверенный контекст пользователя, не принимать от клиента лишние поля владения и регулярно гонять негативные сценарии руками и в автотестах. Работа скучная, зато последствия от её отсутствия обычно куда менее увлекательны.