supersam/docs/security-audit-remediation-...

22 KiB
Raw Blame History

План устранения уязвимостей и защиты персональных данных

Дата аудита: 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 через повторные запросы.

План устранения:

  1. Добавить таблицу 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)
);
  1. Добавить SQL/RPC public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds) с атомарным insert ... on conflict ... update.
  2. Для Edge Functions сделать shared helper supabase/functions/_shared/rate-limit.ts.
  3. Лимиты по умолчанию:
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 минут
  1. Для /login лучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Function request-otp, которая валидирует email, применяет rate limit и только потом инициирует OTP.
  2. Добавить тесты на 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 её обходит.

План устранения:

  1. Разделить Edge Functions на публичные и интеграционные:
    • публичные: get-delivery-invitation, confirm-delivery-choice;
    • внутренние/integration-only: create-delivery-invitation, transfer-to-logistics, report-delivery-result, chatbot-webhook, send-chatbot-message.
  2. Для internal functions требовать Authorization: Bearer <INTEGRATION_API_KEY> или HMAC-подпись:
    • X-Signature: hex(hmac_sha256(raw_body, secret))
    • X-Timestamp
    • X-Request-Id
  3. Отклонять запросы старше 5 минут.
  4. Хранить обработанные X-Request-Id в integration_events или отдельной таблице idempotency_keys.
  5. Добавить verifyIntegrationRequest(request, rawBody) в _shared/auth.ts.
  6. Для 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 опасно.

План устранения:

  1. Добавить shared helper cors.ts.
  2. Настроить allowlist через env:
    • PUBLIC_APP_URL
    • APP_ALLOWED_ORIGINS
    • INTEGRATION_ALLOWED_ORIGINS
  3. Для public invitation endpoints разрешить только домены приложения.
  4. Для integration-only endpoints либо не отвечать CORS вообще, либо разрешить только внутренний origin/n8n.
  5. На неизвестный origin возвращать 403.

P1. Token публичной ссылки не имеет срока жизни и политики ротации

Найдено:

  • delivery_invitations не содержит expires_at.
  • get-delivery-invitation возвращает данные, если token найден и состояние вычислено по заказу.
  • confirm-delivery-choice проверяет active state, но не проверяет срок жизни token.

Риск:

  • Старая ссылка остаётся рабочей слишком долго.
  • Утечка ссылки даёт доступ к данным заказа и действию подтверждения.

План устранения:

  1. Добавить поля:
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;
  1. При создании invitation ставить expires_at = now() + interval '7 days' или другой срок по ТЗ.
  2. В get-delivery-invitation и confirm-delivery-choice возвращать 410 Gone, если expires_at < now() или revoked_at is not null.
  3. После успешного confirm-delivery-choice запрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора.
  4. Добавить ротацию token при повторной отправке ссылки.

P1. Публичная ссылка раскрывает лишние персональные данные

Найдено:

  • get-delivery-invitation возвращает customerName, customerPhone, orderNumber, orderItems, orderStatus, deliveryAgreementStatus.

Риск:

  • При утечке token третье лицо видит телефон, имя, состав заказа и статус.
  • Эти данные могут быть использованы для социальной инженерии.

План устранения:

  1. Минимизировать ответ публичной страницы:
    • показывать имя частично: Мария В.;
    • телефон маскировать: +7 *** ***-12-31;
    • номер заказа показывать частично или только последние 4 символа;
    • состав заказа показывать укрупнённо, если это не нужно клиенту для выбора слота.
  2. Не возвращать orderStatus и deliveryAgreementStatus в сыром виде, отдавать только public state: awaiting_choice, agreed, delivered, expired.
  3. В delivery_invitations хранить public-safe snapshot, чтобы не тянуть весь orders.customer.
  4. Добавить тест, что public API не отдаёт полный телефон и внутренний статус.

P1. Webhook не проверяет подпись провайдера и не ограничивает provider

Найдено:

  • chatbot-webhook берёт provider из query string.
  • Тело события нормализуется без проверки подписи.
  • external_message_id есть, но повторная вставка при конфликте может давать ошибку и 500, а не идемпотентный ok.

Риск:

  • Любой может отправить fake webhook и изменить заказ.
  • Возможен replay одного webhook.
  • Возможна подмена provider.

План устранения:

  1. Для каждого provider добавить проверку подписи:
    • Telegram: secret token/header или webhook secret path.
    • VK/Max: HMAC/secret по контракту провайдера.
  2. Убрать произвольный provider из query или валидировать строго по allowlist.
  3. Для external_message_id реализовать upsert/идемпотентный возврат ok: true при повторе.
  4. В payload логировать только безопасные поля, без полного сырого тела, если оно содержит ПДн.

P1. Нет ограничений на размер JSON и длину полей

Найдено:

  • Edge Functions делают await request.json() без проверки Content-Length.
  • chatbot-webhook записывает text и payload как пришли.
  • confirm-delivery-choice принимает deliveryDate и deliveryTime без строгой проверки формата и allowed values.

Риск:

  • DoS через большой body.
  • Загрязнение истории/логов огромным payload.
  • Некорректные даты и значения слотов.

План устранения:

  1. Добавить helper readJsonBody(request, { maxBytes }).
  2. Лимиты:
    • public endpoints: 8 KB;
    • webhook: 64 KB;
    • internal dispatch: 16 KB.
  3. Валидировать:
    • UUID для orderId;
    • deliveryDate как YYYY-MM-DD;
    • deliveryTime только из available_slots;
    • note, reason, text по максимальной длине.
  4. Возвращать 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/пользователям.
  • Шире нужного доступ к заказам и ПДн.
  • Внутренний пользователь может создать заказ вручную.

План устранения:

  1. Разделить policy users:
    • self может читать только себя;
    • admin читает всех;
    • при необходимости логист/менеджер получают только id, name, role, без email/last_login.
  2. Запретить insert заказов из UI для manager, если 1С является единственным источником.
  3. Ограничить production_lead только source/production полями или убрать роль из текущего демонстрационного контура.
  4. Добавить database policy tests через Supabase local или SQL assertions.

P2. Локальный fallback содержит демонстрационные персональные данные

Найдено:

  • src/services/deliveryInvitationApi.js содержит showcase, client-flow-*, имя, телефон и состав заказа.
  • AuthContext в fallback принимает OTP 000000.

Риск:

  • При production build без env приложение может работать в локальном/demo режиме.
  • Демонстрационные данные могут быть восприняты как реальные.

План устранения:

  1. Добавить build guard: production build падает без VITE_SUPABASE_URL и VITE_SUPABASE_ANON_KEY.
  2. Обернуть fallback только в import.meta.env.DEV.
  3. В production не поддерживать showcase и client-flow-*.
  4. Добавить тест, что в production mode fallback недоступен.

P2. Ошибки возвращают внутренние сообщения наружу

Найдено:

  • Edge Functions возвращают error instanceof Error ? error.message : "Unexpected error" клиенту.

Риск:

  • Утечка деталей Supabase, структуры таблиц, provider errors.

План устранения:

  1. Ввести publicError(status, code, message) и logInternalError(error, context).
  2. Клиенту отдавать стабильные коды: not_found, expired, rate_limited, invalid_payload, temporary_error.
  3. Внутреннюю ошибку писать в 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 день.

  • Сузить users policy.
  • Запретить создание заказов менеджером, если источник только 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.
  • RLS tests:
    • manager видит только свои заказы;
    • logistician видит только назначенные наборы;
    • driver видит только назначенные доставки;
    • обычный пользователь не читает всех users.
  • Manual security checklist:
    • OTP нельзя заспамить;
    • публичная ссылка не раскрывает полный телефон;
    • повторный webhook не дублирует событие;
    • интеграционный endpoint без подписи не меняет заказ.

7. Приоритетный backlog

  1. P0 Rate limits для OTP и public/integration Edge Functions.
  2. P0 Integration auth/HMAC для service-role endpoints.
  3. P0 CORS allowlist вместо *.
  4. P1 Expiration/revocation invitation token.
  5. P1 Минимизация ПДн в public invitation response.
  6. P1 Валидация payload и body size limits.
  7. P1 Ужесточение RLS для users и orders insert.
  8. P2 Production build guard против demo fallback.
  9. P2 Security headers и service-worker cache policy.
  10. P2 Redaction логов и мониторинг подозрительной активности.