404 lines
22 KiB
Markdown
404 lines
22 KiB
Markdown
# План устранения уязвимостей и защиты персональных данных
|
||
|
||
Дата аудита: 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 <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. Добавить поля:
|
||
|
||
```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 логов и мониторинг подозрительной активности.
|