Авторизация: проверяйте права на сервере, не доверяйте клиенту
Знать, кто пользователь, — половина дела; вторая половина — что именно ему можно.
Авторизация — проверка, имеет ли уже аутентифицированный пользователь право на конкретное действие или объект. IDOR — доступ к чужому объекту по подмене его идентификатора.
Аутентификация против авторизации
Их легко спутать. Аутентификация отвечает «кто ты?» (вход по паролю). Авторизация отвечает «что тебе можно?» (этот заказ — твой?). Можно безупречно аутентифицировать пользователя и при этом дать ему чужие данные, если забыть про авторизацию. Broken access control годами держится в топе самых частых уязвимостей именно из-за этой путаницы.
Почему именно авторизация так часто ломается? В отличие от аутентификации, которая обычно сосредоточена в одном месте (форма входа, выдача токена), решения о доступе разбросаны по всему приложению — буквально в каждом эндпоинте, который что-то читает или меняет. Аутентификацию трудно «забыть»: без входа пользователь просто не попадёт внутрь. А вот проверку прав легко упустить в одном из сотен обработчиков, и эта брешь не проявит себя при обычном использовании — честный пользователь ходит только к своим данным и ничего не замечает. Дыра вскрывается лишь тогда, когда кто-то намеренно подставит чужой идентификатор. Поэтому broken access control так живуч: его не видно в демо и в типовых тестах «счастливого пути».
Ещё одна причина — соблазн переложить контроль на интерфейс. Кажется логичным: если у пользователя нет прав, мы просто не покажем ему кнопку или пункт меню. Но интерфейс лишь подсказывает, какие действия доступны; он не контролирует их. Запрос к серверу можно отправить напрямую, минуя любой UI, — через консоль браузера, через инструменты разработчика, повторив сохранённый запрос. Сервер не должен предполагать, что раз кнопка скрыта, то и запрос не придёт. Контроль доступа живёт там, где исполняется действие, — на сервере, а не там, где оно отображается.
IDOR: чужой объект по подмене id
Классика: эндпоинт возвращает объект по id из URL, но не проверяет, принадлежит ли объект текущему пользователю. Поменяв номер, пользователь читает чужое.
// Уязвимо: вернули заказ по id без проверки владельца
GET /api/orders/1042
function getOrder(id) {
return db.orders.find(id); // а вдруг 1042 — чужой заказ?
}
// Безопасно: проверяем, что объект принадлежит текущему пользователю
function getOrder(id, currentUser) {
const order = db.orders.find(id);
if (!order || order.userId !== currentUser.id) {
throw forbidden(); // не своё -> запрет (а не «не найдено» для своих)
}
return order;
}
Сделать id «непредсказуемым» (UUID вместо счётчика) помогает чуть-чуть, но это не авторизация: настоящая защита — проверка прав, а не сокрытие идентификатора.
Чтобы прочувствовать масштаб, представьте интернет-магазин, где счёт-фактуру можно скачать по адресу /invoices/{id}.pdf, а id — простой возрастающий номер. Если проверки владельца нет, любой залогиненный пользователь, меняя число в адресе, выкачивает чужие счета один за другим: имена, суммы, адреса доставки. Это не «угадывание» — последовательные id перебираются автоматически за минуты, и утечёт вся история заказов. Именно так уязвимость в одном-единственном эндпоинте оборачивается массовой утечкой персональных данных. И заметьте: переход на UUID лишь замедлил бы перебор «вслепую», но не закрыл бы доступ — стоит одному чужому UUID попасть в логи, в Referer или в общий чат, как объект снова открыт.
Не доверяйте клиенту
Скрытая в интерфейсе кнопка, флаг isAdmin в запросе, роль из тела запроса — всё это под контролем пользователя. Решение «можно или нет» принимает только сервер, опираясь на серверное представление о роли пользователя.
Сформулируем общее правило, к которому это сводится: всё, что приходит из браузера, — это намерение пользователя, а не истина о его правах. Идентификатор объекта, роль, цена товара, идентификатор «от чьего имени действуем», флаг «я уже подтвердил оплату» — любое из этих значений злоумышленник выставит как захочет, ведь он полностью контролирует свой запрос. Сервер обязан перепроверять каждое такое значение по доверенному источнику: роль и личность — по серверной сессии, принадлежность объекта — по базе, цену — по своему каталогу, а не по тому, что прислал клиент. Источником правды о правах всегда служит сервер, а присланные данные — лишь запрос, который ещё предстоит проверить.
// Уязвимо: доверяем роли из запроса клиента
if (request.body.role === "admin") deleteUser(target);
// Безопасно: роль берём из доверенной серверной сессии/БД
if (currentUser.role === "admin") deleteUser(target); // роль не из запроса
Проверять на каждом запросе и каждом уровне
Авторизация — не разовая проверка «при входе в раздел». Каждый запрос к защищённому ресурсу проверяется заново, в том числе вложенные объекты (доступ к комментарию внутри чужого заказа). Удобный приём — deny by default: маршрут закрыт, пока к нему явно не привязано правило доступа.
Как работает под капотом: централизованная политика
Разбросанные по коду if-проверки легко забыть в одном из мест — и появляется дыра. Зрелые системы выносят авторизацию в один слой: middleware/декоратор на маршруте, единая функция can(user, action, resource) или ABAC/RBAC-движок. Одна точка решения проще проверяется и тестируется, чем десятки разрозненных условий.
У централизации есть и второе, менее очевидное преимущество — её можно полноценно тестировать. Когда правило доступа выражено одной функцией can(user, action, resource), на неё пишут автотесты: владелец видит свой объект, посторонний получает отказ, администратор проходит, гость — нет. Разрозненные if в сотне обработчиков так не покроешь: каждый — отдельный частный случай, и какой-нибудь обязательно забудут. Единая точка решения превращает авторизацию из набора разрозненных привычек в проверяемый инвариант системы.
Связав это с принципом deny by default, получаем удобную модель: новый маршрут по умолчанию закрыт, и чтобы его открыть, разработчик обязан явно привязать к нему правило доступа. Тогда самая опасная ошибка — «забыли проверку» — приводит не к дыре, а к отказу в доступе, который сразу заметят при разработке. Это пример более общей идеи безопасных значений по умолчанию: систему проектируют так, чтобы забывчивость вела к безопасному, а не к открытому состоянию.
запрос -> [middleware: аутентификация] -> [middleware: авторизация can(user, action, res)]
-> обработчик (доходит только разрешённый запрос)
Частые ошибки
- Проверять только аутентификацию. «Залогинен» не значит «имеет право на этот объект».
- Доверять роли/флагам из запроса. Их задаёт клиент; берите из серверной сессии.
- Прятать кнопку вместо проверки на сервере. UI — удобство, а не контроль доступа.
- Полагаться на непредсказуемый id вместо проверки прав. Это не авторизация.
Итоги
- Аутентификация — «кто ты», авторизация — «что тебе можно»; путать нельзя.
- IDOR лечится проверкой владения объектом, а не сокрытием id.
- Решение о доступе принимает только сервер, на каждом запросе, deny by default.
- Выносите авторизацию в единый слой, чтобы не забыть проверку.