Илья Шкура
EN UK RU

Заметки · октябрь 2026

Любой новый аккаунт мог прочитать все заказы гостей.

Как «неизвестно» стало «да»: одно пустое поле в проверке доступа, исправление и проверка своего приложения за две минуты.

Магазин трикотажа собрали в Claude Code по 17 сообщениям обычными словами, на Supabase. Это демонстрационный магазин, собранный для кейса, с вымышленными покупателями и тестовыми данными. Покупатели могут заказывать без аккаунта. Гость после заказа видит страницу заказа по ссылке, в которой есть секретный ключ. Покупатели с аккаунтом открывают свои заказы из аккаунта, а владелец видит все заказы.

Получается три входа, и в проверке одного из них была дыра размером с адреса всех гостей.

Что произошло

Любой, кто заводил аккаунт, мог открыть любой заказ гостя по его номеру. Аккаунт заводился за минуту, подтверждать почту не требовалось. Дальше страница заказа показывала имя гостя, улицу, город, почту, телефон и что он купил.

  • Номера заказов идут подряд, так что легко угадать, какой пробовать следующим.
  • Перебор 60 номеров вниз открыл 29 заказов: каждый заказ гостя среди этих номеров.
  • Примерно четыре заказа из десяти в магазине делали гости.
Страница заказа в сданной версии: только что созданный аккаунт открывает заказ гостя по номеру и видит имя и адрес доставки
Сданная версия: только что созданный аккаунт открывает заказ гостя по номеру. Покупатель вымышленный, в магазине только тестовые данные.

Без входа в аккаунт та же страница отказывала. Именно поэтому такую дыру легко пропустить: при быстрой проверке без аккаунта всё кажется безопасным.

Почему: три вопроса и пустой ответ

Прежде чем показать заказ, база задавала три вопроса. Есть ли в ссылке ключ этого заказа? Ваш ли это заказ? Это владелец? Если ни один ответ не «да», отказать.

if not (
  (p_token is not null and v_order.access_token = p_token)
  or (auth.uid() is not null and v_order.user_id = auth.uid())
  or public.is_owner()
) then
  return null;  -- refuse
end if;

Условие читается правильно, и в этом вся беда. У заказа гостя нет аккаунта, так что его user_id пустой (NULL в SQL). В SQL сравнение чего угодно с пустым значением не даёт ни «да», ни «нет». Оно даёт «неизвестно».

Так что для чужого человека, который вошёл в аккаунт, три ответа были такие: «нет», «неизвестно» и «нет». «Нет, или неизвестно, или нет» даёт «неизвестно», а «не неизвестно» — тоже «неизвестно». if срабатывает только на «да», поэтому отказ так и не сработал, и база отдала заказ. Без входа на второй вопрос ответ был просто «нет», поэтому тогда страница и отказывала.

Само правило на таблице заказов было в порядке. Правило row-level security в Supabase вроде «user_id = auth.uid()» считает «неизвестно» за «нет» и прячет строку. Но эта проверка стояла в функции базы, которая работает с полными правами базы и обходит эти правила. Поэтому решало собственное условие функции if not (…), а оно перевернуло безопасное поведение по умолчанию.

Исправление

Теперь каждый вопрос даёт чёткое «да» или «нет» ещё до того, как их объединяют. Заказ без аккаунта никогда не проходит вопрос «это ваш?», а открывается заказ только тогда, когда ответ — точно «да».

v_allowed :=
  coalesce(p_token is not null and v_order.access_token = p_token, false)
  or coalesce(auth.uid() is not null and v_order.user_id is not null
              and v_order.user_id = auth.uid(), false)
  or coalesce(public.is_owner(), false);
if v_allowed is not true then
  return null;
end if;

А ещё данные страницы больше не содержат внутренних ключей, по которым магазин узнаёт повторный заказ.

Исправленная страница заказа: тот же номер, открытый с чужого аккаунта, теперь показывает «We couldn't open this order»
После исправления: тот же номер, тот же чужой аккаунт, а заказ остаётся закрытым.

Теперь этот случай — тест, который прогоняется на базе при каждом изменении. Чужой аккаунт запрашивает заказ гостя по номеру без ключа, с выдуманным ключом, а также 40 номеров перед ним.

Где ещё такое прячется

Ищите две вещи вместе. Первая — запись без владельца: заказ гостя, черновик, приглашение, которое ещё никто не принял, запись, оставшаяся от удалённого пользователя. Вторая — код, который читает данные с полными правами и сам проверяет доступ. В Supabase это функция security definer или edge function с ключом service role. В Firebase — cloud function с Admin SDK. Там, где эти две вещи встречаются, пустое поле владельца может превратить «неизвестно» в «да».

Проверьте своё приложение за две минуты

  • Заведите два аккаунта, A и B. Под A создайте что-то личное: заказ, счёт, бронирование. Под B откройте это по адресу или номеру. Должен быть отказ.
  • Теперь сделайте то же самое с записью без владельца, например с заказом, сделанным без входа, или с черновиком. Откройте её под B, а потом ещё раз без входа.
  • Если номера в адресе идут подряд, попробуйте несколько соседних с вашим.
  • Если что-то открылось, задайте тому, кто собирал приложение, один вопрос: «Какие функции или серверные маршруты читают данные с полными правами и что делает каждый из них, когда поле владельца пустое?»

Полный кейс со снимками до и после и отчётом на 22 страницы: Интернет-магазин, собранный ИИ: проверен и исправлен. Всё остальное, что нашлось в этом магазине: Магазин, который выглядел готовым.

Хотите проверить эти случаи в своём приложении? Приложения, собранные с ИИ: каждая проблема — с шагами, которые её показывают, с тем, что она раскрывает, и с тестом исправления.