Как один JOIN в обход RLS тихо слил 14 000 клиентских документов

В ноябре прошлого года ко мне на аудит пришел B2B-сервис. Классический стек: React на фронте, Supabase на бэкенде. Фаундер был абсолютно спокоен за данные: «Мы включили Row Level Security на каждой таблице, написали политики по auth.uid(), всё протестировали в дашборде». В дашборде действительно всё выглядело красиво. Прямой запрос к таблице документов возвращал ровно то, что принадлежало текущему пользователю.

Через сорок минут после того, как я подключился со стороны клиента под обычным токеном аутентифицированного пользователя, у меня на диске лежал JSON с 14 280 договорами чужих компаний. Без брутфорса. Без взлома инфраструктуры. Просто один вложенный запрос через PostgREST.

Анатомия тихой утечки

Postgres RLS — потрясающий инструмент, но у него есть неприятное свойство: он не падает с громкой ошибкой 500, когда в логике допущена дыра. База данных просто молча выполняет то, о чем ее попросили, искренне полагая, что вы именно это имели в виду.

Самый частый сценарий провала — это сложные связи, представления (Views) и вспомогательные функции. Разработчики мыслят прямыми таблицами. Допустим, у нас есть таблица invoices и таблица organizations. На обеих висит ENABLE ROW LEVEL SECURITY.

Потом для удобства фронтенда пишется обычный SQL View, который джойнит инвойсы с данными организации, чтобы не тянуть это двумя запросами:

create view client_invoices as
select 
  i.id,
  i.amount,
  i.status,
  o.title as organization_name,
  o.billing_email
from invoices i
join organizations o on o.id = i.organization_id;

В чем здесь подвох? До версии PostgreSQL 15 (и без явного указания параметра security_invoker = true) любое представление создается с правами владельца — обычно это суперпользователь или роль postgres. Когда ваш фронтенд делает запрос к этому View через Supabase API, RLS базовых таблиц попросту игнорируется. Движок базы берет права создателя вьюхи, джойнит всё подряд и отдает наружу данные любой компании, чей id передали в фильтре.

Второй популярный капкан — рекурсивные или некорректно изолированные подзапросы внутри самой политики. Например, политика на доступ к проекту проверяет членство через таблицу project_members, но в самой project_members забыли включить RLS или оставили дыру в проверке ролей. В итоге пользователь X через хитрый JOIN или вложенный ресурс в запросе PostgREST запрашивает /rest/v1/projects?select=*,project_members(*) и вытягивает метаданные чужих рабочих пространств.

Почему дашборд Supabase вам врет

Главная причина, почему такие баги доезжают до продакшена, — способ тестирования. Разработчик открывает Table Editor или SQL Editor в панели управления Supabase и запускает тестовый селект. Всё работает.

Но редактор дашборда работает под ролью postgres или service_role. Для него политик безопасности не существует в принципе. Вы видите мир глазами бога, а ваши клиенты стучатся через роль authenticated или anon с жестко урезанным контекстом JWT.

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

begin;
-- Притворяемся обычным авторизованным пользователем
set local role authenticated;
-- Подставляем конкретный ID атакующего или чужого пользователя
set local "request.jwt.claim.sub" = '11111111-2222-3333-4444-555555555555';

-- Проверяем, отдаст ли база то, что отдавать не должна
select * from client_invoices;
rollback;

Только так можно увидеть, как именно ведут себя ваши политики в боевых условиях.

Что нужно делать прямо сейчас

Я рекомендую ввести в команде три жестких правила разработки для баз данных с RLS:

Первое. Каждое представление (View), отдающееся наружу через API, обязано содержать WITH (security_invoker = true). Если этого параметра нет, считайте, что вы вывесили таблицу в открытый интернет.

Второе. Любые вспомогательные функции, используемые в RLS-политиках и помеченные как SECURITY DEFINER, должны иметь зафиксированный search_path (например, SET search_path = public, auth). Иначе вы рискуете нарваться на перехват контекста выполнения.

Третье. Регулярный глубокий аудит прав. Инструменты вроде линтеров и встроенный supabase rls check отлично находят базовые вещи: таблицы без включенной защиты или политики, разрешающие SELECT true для анонимов. Но никакой автоматический чекер не поймет бизнес-логику ваших связей и не увидит, что через кастомный эндпоинт и забытый `LEFT JOIN` один арендатор может прочитать баланс другого.

Как спать спокойно

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

Мы в GuardLabs регулярно разбираем базы после быстрого масштабирования, находим невидимые глазу дыры в политиках и закрываем их до того, как база уйдет на сторону. Если вам нужен профессиональный Аудит и настройка Row Level Security в Supabase/Postgres — приходите, проверим ваши схемы на прочность под реальными ролями и приведем всё в порядок.