22 KiB
22 KiB
План устранения уязвимостей и защиты персональных данных
Дата аудита: 2026-04-29
1. Контекст и поверхность атаки
Приложение работает как веб-кабинет доставки с персональными данными клиентов: ФИО, телефон, адрес, состав заказа, история доставки, выбранные слоты, сообщения и статусы. Основные поверхности атаки:
/login— вход сотрудников по email OTP./dashboard— авторизованные кабинеты сотрудников./delivery/:token— публичная клиентская ссылка без логина.- Supabase Edge Functions — публичные и интеграционные endpoints.
- Supabase Postgres — таблицы
orders,users,delivery_invitations,chat_messages,order_history,delivery_slots,integration_events. - PWA/localStorage — локальное хранение состояния и клиентских приглашений.
2. Что уже сделано хорошо
- Включены RLS-политики на основных таблицах:
roles,users,orders,order_history,delivery_slots,chat_messages,delivery_invitations,integration_events. - Для
ordersдоступ ограничен ролью и назначением: менеджер, логист, водитель, администратор. - Invitation token в БД хранится как
token_hash, а не как открытый token. - Клиентская ссылка работает без авторизованного пользователя, но доступ идёт по token.
- Email OTP настроен с
shouldCreateUser: false, то есть саморегистрация через фронтенд не должна создавать новых пользователей.
3. Критические риски
P0. Нет rate limits на OTP, публичные ссылки и Edge Functions
Найдено:
src/context/AuthContext.jsx:117вызываетsupabase.auth.signInWithOtp(...)напрямую из браузера.supabase/functions/get-delivery-invitation/index.ts:57иsupabase/functions/confirm-delivery-choice/index.ts:14принимают публичные запросы без лимитов.supabase/functions/create-delivery-invitation/index.ts:17,transfer-to-logistics,report-delivery-result,chatbot-webhookтакже не имеют throttle/rate limit.
Риск:
- Заспамить OTP-письма на один email или набор email.
- Брутфорсить invitation token.
- Массово дергать клиентские ссылки и собирать признаки существующих заказов.
- Перегрузить Edge Functions и базу.
- Создать много
delivery_slots,order_history,integration_eventsчерез повторные запросы.
План устранения:
- Добавить таблицу
public.rate_limits:
create table public.rate_limits (
id uuid primary key default gen_random_uuid(),
scope text not null,
key text not null,
window_start timestamptz not null,
count integer not null default 1,
blocked_until timestamptz,
created_at timestamptz not null default timezone('utc', now()),
updated_at timestamptz not null default timezone('utc', now()),
unique (scope, key, window_start)
);
- Добавить SQL/RPC
public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds)с атомарнымinsert ... on conflict ... update. - Для Edge Functions сделать shared helper
supabase/functions/_shared/rate-limit.ts. - Лимиты по умолчанию:
| Endpoint | Ключ | Лимит |
|---|---|---|
| OTP request | ip + email_hash |
3 запроса / 10 минут, блок 30 минут |
| OTP verify | ip + email_hash |
5 попыток / 10 минут, блок 30 минут |
| get invitation | ip + token_hash_prefix |
30 запросов / 10 минут |
| confirm delivery | ip + token_hash |
5 POST / 10 минут, блок 1 час |
| create invitation | integration_key + order_id |
10 запросов / 10 минут |
| chatbot webhook | provider + external_message_id/ip |
60 запросов / минуту |
| report delivery result | integration_key + order_id |
10 запросов / 10 минут |
- Для
/loginлучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Functionrequest-otp, которая валидирует email, применяет rate limit и только потом инициирует OTP. - Добавить тесты на rate-limit helper: allowed, exhausted, blocked, reset after window.
P0. Service role используется в публичных functions без отдельной авторизации
Найдено:
supabase/functions/_shared/chatbot.ts:18создаёт Supabase client черезSUPABASE_SERVICE_ROLE_KEY.create-delivery-invitation,confirm-delivery-choice,transfer-to-logistics,report-delivery-result,chatbot-webhookиспользуют service client и обходят RLS.create-delivery-invitationпринимаетorderIdиз тела запроса и обновляет заказ через service role.report-delivery-resultиtransfer-to-logisticsтакже меняют заказ поorderIdбез проверки пользователя/подписи.
Риск:
- Если endpoint доступен извне, злоумышленник может менять статусы заказов, создавать приглашения, переводить в платное хранение или доставлено, если узнает/подберёт
orderId. - RLS не помогает, потому что service role её обходит.
План устранения:
- Разделить Edge Functions на публичные и интеграционные:
- публичные:
get-delivery-invitation,confirm-delivery-choice; - внутренние/integration-only:
create-delivery-invitation,transfer-to-logistics,report-delivery-result,chatbot-webhook,send-chatbot-message.
- публичные:
- Для internal functions требовать
Authorization: Bearer <INTEGRATION_API_KEY>или HMAC-подпись:X-Signature: hex(hmac_sha256(raw_body, secret))X-TimestampX-Request-Id
- Отклонять запросы старше 5 минут.
- Хранить обработанные
X-Request-Idвintegration_eventsили отдельной таблицеidempotency_keys. - Добавить
verifyIntegrationRequest(request, rawBody)в_shared/auth.ts. - Для functions, вызываемых из авторизованного UI, проверять JWT пользователя через anon client или
supabase.auth.getUser(jwt), а service role использовать только после проверки роли.
P0. CORS открыт на все origin
Найдено:
Access-Control-Allow-Origin: "*"вget-delivery-invitation,confirm-delivery-choice,create-delivery-invitation,transfer-to-logistics,report-delivery-result,chatbot-webhook.
Риск:
- Любой сайт может дергать публичные endpoints из браузера.
- Для публичной клиентской ссылки это допустимо только частично, но для integration-only endpoints опасно.
План устранения:
- Добавить shared helper
cors.ts. - Настроить allowlist через env:
PUBLIC_APP_URLAPP_ALLOWED_ORIGINSINTEGRATION_ALLOWED_ORIGINS
- Для public invitation endpoints разрешить только домены приложения.
- Для integration-only endpoints либо не отвечать CORS вообще, либо разрешить только внутренний origin/n8n.
- На неизвестный origin возвращать
403.
P1. Token публичной ссылки не имеет срока жизни и политики ротации
Найдено:
delivery_invitationsне содержитexpires_at.get-delivery-invitationвозвращает данные, если token найден и состояние вычислено по заказу.confirm-delivery-choiceпроверяет active state, но не проверяет срок жизни token.
Риск:
- Старая ссылка остаётся рабочей слишком долго.
- Утечка ссылки даёт доступ к данным заказа и действию подтверждения.
План устранения:
- Добавить поля:
alter table public.delivery_invitations
add column if not exists expires_at timestamptz,
add column if not exists revoked_at timestamptz,
add column if not exists last_accessed_at timestamptz,
add column if not exists access_count integer not null default 0;
- При создании invitation ставить
expires_at = now() + interval '7 days'или другой срок по ТЗ. - В
get-delivery-invitationиconfirm-delivery-choiceвозвращать410 Gone, еслиexpires_at < now()илиrevoked_at is not null. - После успешного
confirm-delivery-choiceзапрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора. - Добавить ротацию token при повторной отправке ссылки.
P1. Публичная ссылка раскрывает лишние персональные данные
Найдено:
get-delivery-invitationвозвращаетcustomerName,customerPhone,orderNumber,orderItems,orderStatus,deliveryAgreementStatus.
Риск:
- При утечке token третье лицо видит телефон, имя, состав заказа и статус.
- Эти данные могут быть использованы для социальной инженерии.
План устранения:
- Минимизировать ответ публичной страницы:
- показывать имя частично:
Мария В.; - телефон маскировать:
+7 *** ***-12-31; - номер заказа показывать частично или только последние 4 символа;
- состав заказа показывать укрупнённо, если это не нужно клиенту для выбора слота.
- показывать имя частично:
- Не возвращать
orderStatusиdeliveryAgreementStatusв сыром виде, отдавать только public state:awaiting_choice,agreed,delivered,expired. - В
delivery_invitationsхранить public-safe snapshot, чтобы не тянуть весьorders.customer. - Добавить тест, что public API не отдаёт полный телефон и внутренний статус.
P1. Webhook не проверяет подпись провайдера и не ограничивает provider
Найдено:
chatbot-webhookберётproviderиз query string.- Тело события нормализуется без проверки подписи.
external_message_idесть, но повторная вставка при конфликте может давать ошибку и 500, а не идемпотентныйok.
Риск:
- Любой может отправить fake webhook и изменить заказ.
- Возможен replay одного webhook.
- Возможна подмена provider.
План устранения:
- Для каждого provider добавить проверку подписи:
- Telegram: secret token/header или webhook secret path.
- VK/Max: HMAC/secret по контракту провайдера.
- Убрать произвольный
providerиз query или валидировать строго по allowlist. - Для
external_message_idреализоватьupsert/идемпотентный возвратok: trueпри повторе. - В payload логировать только безопасные поля, без полного сырого тела, если оно содержит ПДн.
P1. Нет ограничений на размер JSON и длину полей
Найдено:
- Edge Functions делают
await request.json()без проверкиContent-Length. chatbot-webhookзаписываетtextиpayloadкак пришли.confirm-delivery-choiceпринимаетdeliveryDateиdeliveryTimeбез строгой проверки формата и allowed values.
Риск:
- DoS через большой body.
- Загрязнение истории/логов огромным payload.
- Некорректные даты и значения слотов.
План устранения:
- Добавить helper
readJsonBody(request, { maxBytes }). - Лимиты:
- public endpoints: 8 KB;
- webhook: 64 KB;
- internal dispatch: 16 KB.
- Валидировать:
- UUID для
orderId; deliveryDateкакYYYY-MM-DD;deliveryTimeтолько изavailable_slots;note,reason,textпо максимальной длине.
- UUID для
- Возвращать
413 Payload Too Largeи422 Unprocessable Entity.
P1. Политики RLS слишком широкие для некоторых ролей
Найдено:
users self or adminфактически разрешает всем авторизованным пользователям читать всех пользователей.orders update by workflow roleпозволяетproduction_leadобновлять все заказы.orders insert managers adminвсё ещё разрешает менеджеру создавать заказы, хотя по текущему ТЗ заказы приходят из 1С.
Риск:
- Лишний доступ к email/пользователям.
- Шире нужного доступ к заказам и ПДн.
- Внутренний пользователь может создать заказ вручную.
План устранения:
- Разделить policy users:
- self может читать только себя;
- admin читает всех;
- при необходимости логист/менеджер получают только
id, name, role, без email/last_login.
- Запретить insert заказов из UI для manager, если 1С является единственным источником.
- Ограничить
production_leadтолько source/production полями или убрать роль из текущего демонстрационного контура. - Добавить database policy tests через Supabase local или SQL assertions.
P2. Локальный fallback содержит демонстрационные персональные данные
Найдено:
src/services/deliveryInvitationApi.jsсодержитshowcase,client-flow-*, имя, телефон и состав заказа.AuthContextв fallback принимает OTP000000.
Риск:
- При production build без env приложение может работать в локальном/demo режиме.
- Демонстрационные данные могут быть восприняты как реальные.
План устранения:
- Добавить build guard: production build падает без
VITE_SUPABASE_URLиVITE_SUPABASE_ANON_KEY. - Обернуть fallback только в
import.meta.env.DEV. - В production не поддерживать
showcaseиclient-flow-*. - Добавить тест, что в production mode fallback недоступен.
P2. Ошибки возвращают внутренние сообщения наружу
Найдено:
- Edge Functions возвращают
error instanceof Error ? error.message : "Unexpected error"клиенту.
Риск:
- Утечка деталей Supabase, структуры таблиц, provider errors.
План устранения:
- Ввести
publicError(status, code, message)иlogInternalError(error, context). - Клиенту отдавать стабильные коды:
not_found,expired,rate_limited,invalid_payload,temporary_error. - Внутреннюю ошибку писать в
error_logs/observability без ПДн.
4. План работ по этапам
Этап 1. Срочная защита от спама и неавторизованных изменений
Срок: 1-2 дня.
- Добавить
rate_limitsи shared rate-limit helper. - Закрыть
create-delivery-invitation,transfer-to-logistics,report-delivery-result,send-chatbot-message,chatbot-webhookчерез integration secret/HMAC. - Ограничить CORS для Edge Functions.
- Добавить проверку размера JSON body.
- Добавить smoke-тесты на
429,401,403.
Критерий готовности:
- Нельзя массово дергать OTP/public endpoints без
429. - Нельзя изменить заказ через integration endpoint без валидного секрета.
- Unknown origin не получает CORS-доступ.
Этап 2. Защита публичной клиентской ссылки
Срок: 1-2 дня.
- Добавить
expires_at,revoked_at,access_count,last_accessed_at. - Проверять срок действия token.
- Маскировать phone/name/orderNumber.
- Убрать внутренние
orderStatusиdeliveryAgreementStatusиз public response. - Проверять
deliveryDate/deliveryTimeпротив доступных слотов. - Сделать подтверждение выбора идемпотентным.
Критерий готовности:
- Просроченная ссылка возвращает
410. - Public API не отдаёт полный телефон и внутренние статусы.
- Повторное подтверждение не создаёт дубли
delivery_slots.
Этап 3. Ужесточение RLS и ролей
Срок: 1 день.
- Сузить
userspolicy. - Запретить создание заказов менеджером, если источник только 1С.
- Проверить права
production_leadи убрать из текущего UI/политик, если роль не используется. - Добавить SQL tests на доступ manager/logistician/driver/admin.
Критерий готовности:
- Сотрудник не может читать список email всех пользователей без нужной роли.
- Менеджер не создаёт заказ напрямую через Supabase client.
- Водитель видит только свои доставки.
Этап 4. Production hardening
Срок: 1 день.
- Отключить demo/local fallback в production.
- Добавить security headers:
Content-Security-Policy;X-Content-Type-Options: nosniff;Referrer-Policy: no-referrer;Permissions-Policy;Strict-Transport-Securityна уровне хостинга.
- Убедиться, что service worker не кеширует ответы с ПДн.
- Добавить redaction для логов: телефоны, email, адреса, token.
Критерий готовности:
- Production build невозможен без Supabase env.
- В кэше нет API-ответов с ПДн.
- Логи не содержат полные телефоны/email/token.
5. Рекомендуемые значения лимитов
| Сценарий | Лимит | Блокировка |
|---|---|---|
| Запрос OTP на email | 3 / 10 минут | 30 минут |
| Проверка OTP | 5 / 10 минут | 30 минут |
| Открытие public invitation | 30 / 10 минут | 10 минут |
| Подтверждение выбора доставки | 5 / 10 минут | 1 час |
| Создание invitation | 10 / 10 минут на orderId | 30 минут |
| Webhook сообщения | 60 / минуту на provider | 5 минут |
| Ошибки 4xx/5xx с одного IP | 100 / 10 минут | 30 минут |
6. Проверки после внедрения
- Unit tests для rate-limit helper.
- Edge Function tests:
- no secret ->
401; - wrong origin ->
403; - too many requests ->
429; - too large body ->
413; - invalid payload ->
422; - expired invitation ->
410.
- no secret ->
- RLS tests:
- manager видит только свои заказы;
- logistician видит только назначенные наборы;
- driver видит только назначенные доставки;
- обычный пользователь не читает всех
users.
- Manual security checklist:
- OTP нельзя заспамить;
- публичная ссылка не раскрывает полный телефон;
- повторный webhook не дублирует событие;
- интеграционный endpoint без подписи не меняет заказ.
7. Приоритетный backlog
P0Rate limits для OTP и public/integration Edge Functions.P0Integration auth/HMAC для service-role endpoints.P0CORS allowlist вместо*.P1Expiration/revocation invitation token.P1Минимизация ПДн в public invitation response.P1Валидация payload и body size limits.P1Ужесточение RLS дляusersиorders insert.P2Production build guard против demo fallback.P2Security headers и service-worker cache policy.P2Redaction логов и мониторинг подозрительной активности.