У твоего приложения нет «внутри»
В классическом сайте между посетителем и базой стоит сервер: он решает, кому что показать. У приложения, собранного с ИИ на Supabase, Firebase или похожем сервисе этого слоя обычно нет. Браузер сам приходит в базу, сам приносит ключ и сам просит данные.
Как выглядит взлом на самом деле
Никто не подбирает пароли и не «взламывает сервер». Вся цепочка занимает минуту и состоит из действий, которые доступны любому посетителю. Ниже — учебный стенд, который мы собрали специально для этого гайда: салон записывает клиентов, данные вымышленные.
Первое, что делает любопытный посетитель, — открывает исходный код страницы. Это одно нажатие, никаких инструментов не нужно.
service_role, когда его попросили «сделать удаление заявок из
панели». Формально задача решена. Фактически ключ, обходящий все правила, теперь у каждого
посетителя.Дальше даже ключ не обязателен. Если у таблицы выключен RLS, публичного ключа хватает, чтобы прочитать её целиком — прямо в адресной строке.
имя-проекта.supabase.co
перебираются автоматически, названия таблиц берутся из типовых: users,
leads, orders, messages. Проекту не нужно быть известным,
чтобы попасть в такой перебор, — достаточно быть доступным.
Один ключ публичный, другой — нет
Главная путаница новичка: «у меня ключ виден в браузере — я всё сломал?». Не обязательно. Ключей два, и они противоположны по смыслу.
| Ключ | Где ему место | Что он умеет | Если попал в браузер |
|---|---|---|---|
anon (публичный) |
В коде страницы. Иначе она не сможет обратиться к базе. | Ровно то, что разрешают политики RLS. Сам по себе прав не даёт. | так и задумано |
service_role (секретный) |
Только на сервере: в переменных окружения, в фоновом скрипте, в CI. | Всё. Он обходит RLS целиком: читает, меняет и удаляет любые строки. | проект скомпрометирован |
| Токены и пароли сервисов | Там же, где service_role: только на сервере. |
Пишут в чужие системы: рассылают почту, списывают деньги, тратят твой лимит у ИИ. | счёт оплачиваешь ты |
Как отличить их глазами
Ключи Supabase — это три части, разделённые точками. Средняя часть кодирует роль. Если в ней
встречается service_role — это секретный ключ, и в браузере ему не место. Строка
anon — публичный. Проверять на глаз необязательно: в настройках проекта они лежат в
разных блоках, и секретный там прямо помечен как секретный.
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 не кричит «доступ запрещён» — он просто не показывает строки. Пустой список в ответ на чтение и пустой список в ответ на обновление означают одно: под правила не попало ни одной строки. Это успешная защита, а не поломка.
А вот когда таблицу закрывают жёстче — на уровне прав роли, а не строк, — ответ становится явным. Так закрыта очередь писем на нашем сайте: браузерному ключу её не отдают вообще.
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();
Мы пришли к этому не из книжки: накрутка счётчика прямым запросом была одной из находок нашего собственного аудита. Добавляешь новое служебное поле — сразу добавляй его в такой триггер, иначе оно останется открытым ровно до первого любопытного.
Storage закрывается отдельно
Про таблицы вспоминают, про файлы — нет. Между тем в бакеты попадает самое неприятное: сканы документов, фото пользователей, выгрузки, «черновик договора.pdf».
Проверяется одним запросом: попробуй получить список объектов публичным ключом, как это сделал бы посторонний.
curl -s -X POST \
"https://ТВОЙ-ПРОЕКТ.supabase.co/storage/v1/object/list/ИМЯ-БАКЕТА" \
-H "apikey: ПУБЛИЧНЫЙ-КЛЮЧ" \
-H "Content-Type: application/json" \
-d '{"prefix":"","limit":5}'
Пустой список [] — правильный ответ: листинг запрещён. Перечень файлов в ответе означает,
что содержимое бакета публично, даже если ссылки нигде не опубликованы.
Заголовки: пять строк, которые ставятся один раз
База закрыта — остаётся сама страница. Заголовки ответа сервера решают, что браузер разрешит ей делать. Ставятся один раз в настройках хостинга и дальше работают молча.
script-src 'self' и добавляй домены по одному, когда что-то перестаёт
работать.Посмотреть, что отдаёт твой сайт сейчас:
curl -sI https://ТВОЙ-САЙТ/ | grep -i \
"content-security-policy\|strict-transport\|x-content-type\|referrer-policy"
Пустой вывод означает, что не стоит ничего.
Чек-лист перед публикацией
Пять проверок по порядку. Все — только чтение: они ничего не меняют в проекте. Подставь свои адрес проекта, публичный ключ и названия таблиц.
- Секретные ключи в коде. Открой свой сайт, нажми «Посмотреть код страницы» и
поищи по тексту
service_role,password,secret,sk-. То же самое — по файлам.jsв проекте. Найденное считается утёкшим. - Открытые таблицы. Для каждой таблицы, где есть чужие данные, выполни запрос
публичным ключом. Возвращаются чужие строки — RLS выключен или политика слишком
щедрая.
curl -s "https://ТВОЙ-ПРОЕКТ.supabase.co/rest/v1/ТАБЛИЦА?select=*&limit=3" \ -H "apikey: ПУБЛИЧНЫЙ-КЛЮЧ" - Служебные колонки. Выпиши поля, которые пользователь менять не должен: счётчики, роль, статус, даты, автора. Убедись, что они защищены триггером, а не только тем, что в интерфейсе нет такой кнопки.
- Файлы. Попробуй получить список объектов каждого бакета (команда из раздела про Storage). Всё, что не предназначено для витрины, должно лежать в приватном бакете.
- Заголовки сайта. Проверь ответ сервера командой из предыдущего раздела. Отсутствующие заголовки добавь в настройках хостинга.
Семь приёмов, которые мы гоняем по своей базе
У нас это отдельный скрипт: он ходит в боевую базу публичным ключом и пытается сделать то, чего делать нельзя. Прогон обязателен после каждой правки правил доступа. Ниже — что именно он пробует.
- 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"}
Смысл не в командах, а в привычке: правила доступа проверяются прогоном, а не памятью. Половина находок нашего аудита появилась именно так — руками их никто бы не заметил.
Порядок действий, когда ключ нашёлся в браузере
Паниковать рано, но и «уберу строчку потом» не годится: ключ уже мог быть скопирован сканером. Делай по порядку — важна именно последовательность.
Отзови ключ
В настройках проекта сгенерируй новые ключи. Старый перестаёт работать — вместе с ним перестаёт работать и тот, кто им пользовался. Это первое действие, а не последнее: пока ключ жив, всё остальное бессмысленно.
Убери ключ из кода и пересобери сайт
Удалить строку недостаточно: старая версия остаётся в истории репозитория и в кеше браузеров. Если репозиторий публичный — считай ключ утёкшим навсегда, даже после удаления коммита. Новый ключ должен попасть в переменные окружения, а не в файл рядом с кодом.
Посмотри, что успели сделать
В журнале запросов проекта видно обращения с этим ключом: чужие адреса, массовые чтения, удаления. Отдельно проверь, не появились ли новые администраторы и не пропали ли записи.
Закрой дыру, из-за которой ключ понадобился
Обычно service_role попадает в браузер потому, что интерфейсу нужно действие с
правами администратора. Такое действие выносят на сервер — в фоновый обработчик или
функцию, — а страница просит его выполнить, не зная секретов.
Если утекли чужие персональные данные
Утечка имён, телефонов и адресов клиентов — это уже не только техническая история: у оператора персональных данных есть обязанность уведомить регулятора. Сроки короткие, поэтому решение о том, была ли утечка, лучше принимать сразу, а не через неделю.
Что спрашивают чаще всего
anon — так и задумано, без него страница
не работает. Проблема только если это service_role.Мы прошли этот список по себе
Всё, что здесь написано, — из нашего собственного аудита клубного сайта: накрутка счётчика прямым запросом, подмена даты создания, доступность служебных таблиц, ключи в бандле. Часть находок закрыта политиками, часть — триггерами, часть — тем, что данные вообще перестали отдаваться браузеру. Прогон проверок с тех пор обязателен после каждой правки правил доступа.
Нашёл в гайде неточность или знаешь приём, которого здесь нет? Напиши в чат сообщества — дополним и укажем дату.
Застрял на каком-то пункте?
Правила доступа — то место, где чаще всего непонятно не «как», а «что вообще должно получиться». Принеси свой случай в чат сообщества: там сидят люди, которые проходили этот же список по своим проектам, и разбирают по-человечески, а не ссылкой на англоязычную документацию.