# План устранения уязвимостей и защиты персональных данных Дата аудита: 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`: ```sql 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) ); ``` 2. Добавить SQL/RPC `public.check_rate_limit(scope, key, max_count, window_seconds, block_seconds)` с атомарным `insert ... on conflict ... update`. 3. Для Edge Functions сделать shared helper `supabase/functions/_shared/rate-limit.ts`. 4. Лимиты по умолчанию: | 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 минут | 5. Для `/login` лучше не вызывать Supabase OTP напрямую из браузера, а добавить Edge Function `request-otp`, которая валидирует email, применяет rate limit и только потом инициирует OTP. 6. Добавить тесты на 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 ` или 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. Добавить поля: ```sql 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; ``` 2. При создании invitation ставить `expires_at = now() + interval '7 days'` или другой срок по ТЗ. 3. В `get-delivery-invitation` и `confirm-delivery-choice` возвращать `410 Gone`, если `expires_at < now()` или `revoked_at is not null`. 4. После успешного `confirm-delivery-choice` запрещать повторное подтверждение, кроме идемпотентного возврата текущего выбора. 5. Добавить ротацию 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 логов и мониторинг подозрительной активности.