Авторизация: проверяйте права на сервере, не доверяйте клиенту

Знать, кто пользователь, — половина дела; вторая половина — что именно ему можно.

Авторизация — проверка, имеет ли уже аутентифицированный пользователь право на конкретное действие или объект. 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.
  • Выносите авторизацию в единый слой, чтобы не забыть проверку.
Проверьте себя
1. В чём разница между аутентификацией и авторизацией?
AЭто синонимы
BАутентификация устанавливает, кто пользователь; авторизация — что ему разрешено делать с конкретным ресурсом
CАвторизация — это вход по паролю
DАутентификация проверяет права на объект
2. Как правильно защититься от IDOR?
AСделать идентификаторы случайными UUID
BНа сервере проверять, что запрашиваемый объект принадлежит текущему пользователю (или есть право доступа)
CПрятать ссылку на объект в интерфейсе
DШифровать id в URL
3. Почему нельзя принимать роль пользователя (например, isAdmin) из тела запроса?
AЭто замедляет запрос
BТело запроса контролирует клиент и может выставить любую роль; роль нужно брать из доверенной серверной сессии или БД
CJSON не поддерживает булевы значения
DРоль должна храниться в localStorage