чек-лист / безопасность вайбкод-проекта

Проверь свой проект,
пока его не проверил
кто-то другой

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

до всех проверок

У твоего приложения нет «внутри»

В классическом сайте между посетителем и базой стоит сервер: он решает, кому что показать. У приложения, собранного с ИИ на Supabase, Firebase или похожем сервисе этого слоя обычно нет. Браузер сам приходит в базу, сам приносит ключ и сам просит данные.

следствие 1Всё, что попало в код страницы, видно посетителю. Ключи, адреса, пароли, комментарии ИИ — открой «Посмотреть код» и читай.
следствие 2Любой запрос, который умеет делать твоя страница, может повторить посторонний. Кнопку «удалить» можно не нажимать — достаточно позвать тот же адрес руками.
следствие 3Единственное место, где решается «можно или нельзя», — сама база. Проверки в JavaScript ничего не охраняют: они на стороне того, кого проверяют.
Это не недостаток вайбкодинга. Так же устроены и приложения, написанные руками, — просто там про правила доступа помнят с первого дня. ИИ по умолчанию пишет код, который работает, а не код, который защищён: он выполняет то, о чём его попросили. Про правила доступа надо попросить отдельно.
01
три шага

Как выглядит взлом на самом деле

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

шаг 1обычная админка, ничего подозрительного
Админка салона со списком заявок: имена, телефоны, суммы
Приложение работает. Имена, телефоны и комментарии клиентов лежат в базе и приезжают в браузер администратора.

Первое, что делает любопытный посетитель, — открывает исходный код страницы. Это одно нажатие, никаких инструментов не нужно.

шаг 2ключ администратора лежит в коде
Исходный код страницы: в нём видны ключ service_role и пароль администратора
ИИ добавил service_role, когда его попросили «сделать удаление заявок из панели». Формально задача решена. Фактически ключ, обходящий все правила, теперь у каждого посетителя.

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

шаг 3вся таблица одним адресом
Ответ базы: JSON со всеми заявками, именами и телефонами клиентов
Ни входа, ни пароля. База отдаёт всё, потому что её никто не просил не отдавать.
Так делают не люди, а сканеры. Адреса вида имя-проекта.supabase.co перебираются автоматически, названия таблиц берутся из типовых: users, leads, orders, messages. Проекту не нужно быть известным, чтобы попасть в такой перебор, — достаточно быть доступным.
02
ключи

Один ключ публичный, другой — нет

Главная путаница новичка: «у меня ключ виден в браузере — я всё сломал?». Не обязательно. Ключей два, и они противоположны по смыслу.

Ключ Где ему место Что он умеет Если попал в браузер
anon (публичный) В коде страницы. Иначе она не сможет обратиться к базе. Ровно то, что разрешают политики RLS. Сам по себе прав не даёт. так и задумано
service_role (секретный) Только на сервере: в переменных окружения, в фоновом скрипте, в CI. Всё. Он обходит RLS целиком: читает, меняет и удаляет любые строки. проект скомпрометирован
Токены и пароли сервисов Там же, где service_role: только на сервере. Пишут в чужие системы: рассылают почту, списывают деньги, тратят твой лимит у ИИ. счёт оплачиваешь ты

Как отличить их глазами

Ключи Supabase — это три части, разделённые точками. Средняя часть кодирует роль. Если в ней встречается service_role — это секретный ключ, и в браузере ему не место. Строка anon — публичный. Проверять на глаз необязательно: в настройках проекта они лежат в разных блоках, и секретный там прямо помечен как секретный.

Просьба, которую стоит держать в промпте. «Секретные ключи не пиши в код фронтенда; всё, что требует прав администратора, выноси на сервер». ИИ выполняет это охотно — он просто не додумывает такие требования сам.
03
главная защита

RLS: правила живут в базе

Row Level Security — это правила, которые база проверяет сама, для каждого запроса, независимо от того, кто и откуда пришёл. Обойти их со стороны браузера нельзя: они и есть охрана, которой нет в коде.

Включается одной командой в SQL-редакторе:

alter table leads enable row level security;

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

-- любой может создать заявку
create policy "anyone can submit"
  on leads for insert
  to anon
  with check (true);

-- читает только владелец салона
create policy "owner reads"
  on leads for select
  to authenticated
  using (auth.uid() = owner_id);

Отказ выглядит не как ошибка

Здесь спотыкаются почти все. RLS не кричит «доступ запрещён» — он просто не показывает строки. Пустой список в ответ на чтение и пустой список в ответ на обновление означают одно: под правила не попало ни одной строки. Это успешная защита, а не поломка.

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

наш APIответ боевой базы, не стенда
Ответ базы: код 42501, permission denied for table notification_outbox
Код 42501 — «нет прав на таблицу». Так должна отвечать любая служебная таблица, которой в браузере делать нечего.

RLS ограничивает строки, но не колонки

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

счётчикиЛайки, просмотры, рейтинг. Накручиваются одним запросом, без всякой кнопки.
роль и статусrole, is_admin, status. Пользователь делает себя администратором или публикует свою запись мимо модерации.
даты и авторствоcreated_at, author_id. Запись залезает наверх ленты или меняет автора.

Лечится тем, что служебные поля вообще не принимают снаружи. Надёжный способ — триггер, который возвращает старое значение при попытке его изменить:

create or replace function protect_privileged_columns()
returns trigger
language plpgsql
security definer
as $$
begin
  new.upvotes    := old.upvotes;
  new.created_at := old.created_at;
  new.author_id  := old.author_id;
  return new;
end;
$$;

create trigger protect_projects_columns
  before update on projects
  for each row execute function protect_privileged_columns();

Мы пришли к этому не из книжки: накрутка счётчика прямым запросом была одной из находок нашего собственного аудита. Добавляешь новое служебное поле — сразу добавляй его в такой триггер, иначе оно останется открытым ровно до первого любопытного.

04
файлы

Storage закрывается отдельно

Про таблицы вспоминают, про файлы — нет. Между тем в бакеты попадает самое неприятное: сканы документов, фото пользователей, выгрузки, «черновик договора.pdf».

публичный бакетСсылка на файл работает у всех, у кого она есть. Для обложек и аватарок это нормально.
листингОтдельная беда: если разрешён список объектов, ссылку и знать не надо — бакет сам покажет, что в нём лежит.
приватный бакетФайлы отдаются только по временной подписанной ссылке. Всё, что не картинка на витрине, должно жить здесь.

Проверяется одним запросом: попробуй получить список объектов публичным ключом, как это сделал бы посторонний.

curl -s -X POST \
  "https://ТВОЙ-ПРОЕКТ.supabase.co/storage/v1/object/list/ИМЯ-БАКЕТА" \
  -H "apikey: ПУБЛИЧНЫЙ-КЛЮЧ" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"","limit":5}'

Пустой список [] — правильный ответ: листинг запрещён. Перечень файлов в ответе означает, что содержимое бакета публично, даже если ссылки нигде не опубликованы.

05
сам сайт

Заголовки: пять строк, которые ставятся один раз

База закрыта — остаётся сама страница. Заголовки ответа сервера решают, что браузер разрешит ей делать. Ставятся один раз в настройках хостинга и дальше работают молча.

Content-Security-PolicyСписок источников, откуда странице можно грузить скрипты. Главная защита от подставленного чужого кода. Начни с script-src 'self' и добавляй домены по одному, когда что-то перестаёт работать.
Strict-Transport-SecurityЗапрещает открывать сайт по незашифрованному http.
X-Content-Type-Options: nosniffНе даёт браузеру угадывать тип файла и выполнять картинку как скрипт.
Referrer-PolicyПерестаёт рассказывать чужим сайтам, с какой именно твоей страницы пришёл человек.
frame-ancestors 'none'Запрещает встраивать твой сайт в чужой фрейм — приём, которым подделывают нажатия.

Посмотреть, что отдаёт твой сайт сейчас:

curl -sI https://ТВОЙ-САЙТ/ | grep -i \
  "content-security-policy\|strict-transport\|x-content-type\|referrer-policy"

Пустой вывод означает, что не стоит ничего.

двадцать минут

Чек-лист перед публикацией

Пять проверок по порядку. Все — только чтение: они ничего не меняют в проекте. Подставь свои адрес проекта, публичный ключ и названия таблиц.

  1. Секретные ключи в коде. Открой свой сайт, нажми «Посмотреть код страницы» и поищи по тексту service_role, password, secret, sk-. То же самое — по файлам .js в проекте. Найденное считается утёкшим.
  2. Открытые таблицы. Для каждой таблицы, где есть чужие данные, выполни запрос публичным ключом. Возвращаются чужие строки — RLS выключен или политика слишком щедрая.
    curl -s "https://ТВОЙ-ПРОЕКТ.supabase.co/rest/v1/ТАБЛИЦА?select=*&limit=3" \
      -H "apikey: ПУБЛИЧНЫЙ-КЛЮЧ"
  3. Служебные колонки. Выпиши поля, которые пользователь менять не должен: счётчики, роль, статус, даты, автора. Убедись, что они защищены триггером, а не только тем, что в интерфейсе нет такой кнопки.
  4. Файлы. Попробуй получить список объектов каждого бакета (команда из раздела про Storage). Всё, что не предназначено для витрины, должно лежать в приватном бакете.
  5. Заголовки сайта. Проверь ответ сервера командой из предыдущего раздела. Отсутствующие заголовки добавь в настройках хостинга.
Проверяй только свой проект. Те же команды, направленные на чужой сайт, — это уже не проверка, а неавторизованный доступ, и отвечать за него придётся по закону. Нашёл чужую открытую базу — напиши владельцу, а не открывай её.
security-check — прогон по нашему сайту
как мы проверяем себя

Семь приёмов, которые мы гоняем по своей базе

У нас это отдельный скрипт: он ходит в боевую базу публичным ключом и пытается сделать то, чего делать нельзя. Прогон обязателен после каждой правки правил доступа. Ниже — что именно он пробует.

1
поднять себе роль до администратора;
2
самому поставить своему проекту клубный статус;
3
накрутить счётчик голосов прямым запросом;
4
подменить дату создания и залезть наверх ленты;
5
получить список файлов в бакете обложек;
6
записать что-нибудь в таблицу обратной связи анонимно;
7
прочитать очередь писем и токены отписки.

Так выглядят два ответа из этого прогона — запрос без ключа и запрос к служебной таблице:

curl https://api.wedesignerz.com/rest/v1/projects?select=id
{"message":"No API key found in request"}

curl -H "apikey: ПУБЛИЧНЫЙ-КЛЮЧ" \
     https://api.wedesignerz.com/rest/v1/notification_outbox?select=*
{"code":"42501","message":"permission denied for table notification_outbox"}

Смысл не в командах, а в привычке: правила доступа проверяются прогоном, а не памятью. Половина находок нашего аудита появилась именно так — руками их никто бы не заметил.

если уже утекло

Порядок действий, когда ключ нашёлся в браузере

Паниковать рано, но и «уберу строчку потом» не годится: ключ уже мог быть скопирован сканером. Делай по порядку — важна именно последовательность.

01

Отзови ключ

В настройках проекта сгенерируй новые ключи. Старый перестаёт работать — вместе с ним перестаёт работать и тот, кто им пользовался. Это первое действие, а не последнее: пока ключ жив, всё остальное бессмысленно.

02

Убери ключ из кода и пересобери сайт

Удалить строку недостаточно: старая версия остаётся в истории репозитория и в кеше браузеров. Если репозиторий публичный — считай ключ утёкшим навсегда, даже после удаления коммита. Новый ключ должен попасть в переменные окружения, а не в файл рядом с кодом.

03

Посмотри, что успели сделать

В журнале запросов проекта видно обращения с этим ключом: чужие адреса, массовые чтения, удаления. Отдельно проверь, не появились ли новые администраторы и не пропали ли записи.

04

Закрой дыру, из-за которой ключ понадобился

Обычно service_role попадает в браузер потому, что интерфейсу нужно действие с правами администратора. Такое действие выносят на сервер — в фоновый обработчик или функцию, — а страница просит его выполнить, не зная секретов.

05

Если утекли чужие персональные данные

Утечка имён, телефонов и адресов клиентов — это уже не только техническая история: у оператора персональных данных есть обязанность уведомить регулятора. Сроки короткие, поэтому решение о том, была ли утечка, лучше принимать сразу, а не через неделю.

короткие ответы

Что спрашивают чаще всего

ключ виденПубличный anon — так и задумано, без него страница не работает. Проблема только если это service_role.
пустой ответЭто отказ, а не поломка. RLS не возвращает ошибку — он не показывает строки.
RLS включёнПроверь колонки. Строки закрыты, а служебные поля внутри своей строки — нет, пока их не защитит триггер.
кому я нуженОткрытые базы находят сканеры перебором, а не люди по интересу. Размер проекта не влияет.
ИИ проверит?Спроси у него «какие таблицы у меня открыты» — он ответит по коду, который видит, и не увидит настроек базы. Ответ базы честнее.
сколько это стоитПроверка по чек-листу — двадцать минут и ноль рублей. Дороже обходится только пропущенный пункт.
откуда мы это знаем

Мы прошли этот список по себе

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

Нашёл в гайде неточность или знаешь приём, которого здесь нет? Напиши в чат сообщества — дополним и укажем дату.

дальше

Застрял на каком-то пункте?

Правила доступа — то место, где чаще всего непонятно не «как», а «что вообще должно получиться». Принеси свой случай в чат сообщества: там сидят люди, которые проходили этот же список по своим проектам, и разбирают по-человечески, а не ссылкой на англоязычную документацию.

Спросить в чате