Берегите очко смолоду
Часть 4 — про авторизацию и IDOR.
Что здесь важно для продукта#
IDOR опасен тем, что выглядит как обычная ссылка или номер записи. Для пользователя это не «сложная уязвимость», а ситуация, где чужой счёт, документ или заявка открываются простым перебором адреса.
Для владельца сервиса вывод прямой: проверять нужно не только факт входа, но и право на каждый конкретный объект. Если человек авторизован, это ещё не значит, что ему можно видеть любые данные внутри системы.
Самый неприятный баг, который мне за последнее время показали, выглядел так: человек открыл свой счёт за услугу, увидел в адресной строке id=10472, поменял на 10471 — и получил чужую квитанцию. С чужим именем, чужой суммой и чужим адресом. Никакого взлома, никакого хитрого payload. Просто минус единица.
Это и есть IDOR — Insecure Direct Object Reference. Звучит страшно, а по сути это всегда одна и та же ошибка: сервер проверил, что ты вошёл, но забыл проверить, что конкретный объект принадлежит тебе. Аутентификация есть, авторизации нет. И таких историй у меня накопилось на целую статью.
Проверяю права после того, как уже всё сломал#
Самое коварное — внешне код выглядит правильно. Эндпоинт принимает вложение, перепривязывает его к другой записи, что-то пересчитывает — и только в конце, перед ответом, спохватывается: «а имел ли пользователь право трогать этот ресурс?».
async function moveAttachment(userId, attachmentId, targetId) {
await db.attachment.update({
where: { id: attachmentId },
data: { targetId },
});
await recalcTotals(targetId);
const owner = await checkOwnership(userId, attachmentId);
if (!owner) throw new ForbiddenError();
}
Заметили? Проверка стоит, она даже честная. Но к моменту, когда она срабатывает, данные уже изменены и пересчёт уже прошёл. Бросить исключение в конце — это не защита, это запоздалое сожаление. Side-effect случился.
async function moveAttachment(userId, attachmentId, targetId) {
const owner = await checkOwnership(userId, attachmentId);
if (!owner) throw new ForbiddenError();
await db.attachment.update({
where: { id: attachmentId },
data: { targetId },
});
await recalcTotals(targetId);
}
Правило простое до зевоты, но его нарушают постоянно: авторизация идёт до любого изменения состояния и до любого ответа. Не после. Никогда не после.
Инкрементный id и перебор по соседям#
Та самая история про квитанцию 10471. Эндпоинт статуса платежа брал последовательный числовой id и возвращал данные, ни у кого не спрашивая, чей это платёж. Аутентификация формально была — токен валидный. А вот вопрос «это вообще твой платёж?» никто не задавал.
Перебрать диапазон чисел — дело нескольких минут скриптом. Лечится двумя движениями. Первое и обязательное: проверять принадлежность ресурса при любом запросе статуса, до side-effects.
const payment = await db.payment.findUnique({ where: { id } });
if (!payment || payment.userId !== currentUser.id) throw new NotFoundError();
Второе, по возможности: не светить наружу угадываемые идентификаторы. UUID вместо автоинкремента не заменяет проверку прав, но убирает сам соблазн «а что там у соседа».
Возвращаю всю модель целиком#
Любимое. Берём объект из базы и отдаём его наружу как есть — return user. Удобно, быстро, и вместе с именем и аватаркой улетают хеш пароля, токен сессии, внутренние флаги и привязка к арендатору. В мультиарендной системе это уже не просто лишние поля — это утечка данных между клиентами.
res.json(await db.user.findUnique({ where: { id } }));
Сериализация ответа должна быть явной. White-list полей, отдельный DTO, и наружу едет ровно то, что ты сознательно решил отдать.
const user = await db.user.findUnique({ where: { id } });
res.json({ id: user.id, name: user.name, avatar: user.avatar });
«Возвращаю всю модель» — это не экономия времени, это мина замедленного действия: добавили завтра в таблицу новое чувствительное поле, и оно автоматически поехало в каждый ответ.
Заблокировал, но не до конца#
Был флаг isBlocked. Проверялся он честно — на каждом запросе с access-токеном. Только вот путь refresh про этот флаг ничего не знал. Заблокированный пользователь просто дёргал обновление токена и продолжал жить, как будто ничего не случилось.
Инварианты авторизации — «активен ли аккаунт», «не заблокирован ли», «не истекла ли подписка» — должны проверяться на каждом пути выдачи доступа. И access, и refresh, и повторный вход. Лучше всего вынести это в одну общую точку, чтобы не держать три копии логики, одна из которых обязательно отстанет.
function issueTokens(user) {
assertActive(user); // одна проверка для всех путей выдачи
return { access: signAccess(user), refresh: signRefresh(user) };
}
«Ты в группе — значит, тебе можно»#
Ещё одна классика. Привилегированное действие — что-то поудалять, поменять настройки общего ресурса — защищалось проверкой «состоит ли пользователь в нужном чате/группе». Сам факт членства приравняли к праву. Но быть участником и иметь право администрировать — это разные вещи.
Действие надо привязывать к роли и к ожидаемому контексту, а не к факту присутствия. «Этот пользователь — администратор именно этого пространства» — вот вопрос, на который должен отвечать код. Не «он вообще тут есть».
Сам себе реферал#
IDOR бывает не только про «прочитал чужой объект». Иногда это бизнес-право, которое забыли проверить в одном из входов. Например, реферальная система честно запрещает самореферал по Telegram ID, но в web/email-сценарии этого ID ещё нет. Пользователь приходит по своей же ссылке, система не узнаёт его как себя и начисляет бонус.
Проверка должна опираться на каноническую текущую учётку, а не на один внешний идентификатор. Если пользователь уже известен системе, в резолв реферального или партнёрского start-param надо передавать currentUserId и отказывать, когда источник совпадает с получателем или образует кольцо.
// плохо: сравнили только один внешний id, которого может не быть
if (referrer.telegramId === currentTelegramId) return null;
// хорошо: проверяем внутреннюю идентичность и бизнес-инвариант
if (referrer.id === currentUserId) return null;
if (await createsReferralRing(referrer.id, currentUserId)) return null;
Главный урок тот же, что и в обычном IDOR: право не выводится из формы ссылки. Его проверяет сервер по своей модели данных, во всех входах одинаково.
Когда второй вход обходит первый#
Публичные кейсы показывают, что IDOR и слабая авторизация — это почти всегда не один очевидный id=10471, а целая цепочка.
В Taimi (bobdahacker.com/blog/taimi-idor) видео-аттачменты были защищены, а вот превью локаций открывало тот же ресурс по другому пути (photoId) без тех же проверок. В FIFA (bobdahacker.com/blog/fifa-hack) интерфейс честно говорил «NO_ROLES / access denied», а бэкенд отдавал RTMP-ключи, live-матчи, стриминг-панель и операции записи по аккаунту в общем Entra-тенанте. Frontier Airlines (bobdahacker.com/blog/frontier-airlines-hack) маскировал персональные данные в интерфейсе, но сырой booking-объект с паспортами, адресами, TSA PreCheck и платежами спокойно лежал в API и HTML/JS-блобах.
Вывод простой и неприятный: один защищённый путь ничего не значит, если есть второй (или третий) вход к тому же ресурсу. Идентификаторы, которые фактически работают как bearer-токены (PNR, attach ID, JID), проверки только на клиенте и совместимость со старыми приложениями — всё это классика, которая регулярно всплывает.
Заплати сначала#
И напоследок самое прозаичное: платный ресурс отдавался всем, кто аутентифицирован, без проверки активной подписки. Логика была «раз ты вошёл — значит, наш клиент». А клиент мог отменить оплату три месяца назад.
Аутентификация отвечает на вопрос «кто ты». Авторизация — «что тебе можно прямо сейчас». Перед выдачей платного контента проверяется именно второе: есть ли у этого пользователя действующее право на этот конкретный ресурс.
Что отсюда забрать
Если убрать частности, все шесть историй — про одну дырку в мышлении. Мы привыкли думать, что главный барьер — это вход. Залогинился — свой. А на деле каждый запрос к каждому объекту должен заново отвечать на вопрос «а тебе-то это можно?». До изменений, до ответа, на любом пути, явным списком полей. Скучно, повторяемо, неромантично. Зато сосед не читает твою квитанцию.
Если в сервисе есть роли, кабинеты, заявки, заказы или документы, авторизацию нужно проверять не только на входе. Напишите мне в Telegram или на почту — помогу пройти объекты и права доступа так, чтобы один пользователь не видел и не менял чужие данные.



