Ілля Шкура
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 сторінки: Інтернет-магазин, зібраний ШІ: перевірено й виправлено. Усе інше, що знайшлося в цьому магазині: Магазин, що мав готовий вигляд.

Хочете перевірити ці випадки у своєму застосунку? Застосунки, зібрані з ШІ: кожна проблема — з кроками, що її показують, з тим, що вона розкриває, і з тестом виправлення.