v0.1.16: JWT auth via nubes API, auto-provisioning, UI login

- app/auth/jwt.go: ParseJWTClaims, TenantIDFromSub (sless-compatible SHA256), PingNubesAPI
- app/admin/admin.go: POST /ui/api/auth endpoint, jwtMiddleware for /ui/api/*
- app/tenant/tenant_store.go: NubesSub/Email fields, GetBySub, CreateFromJWT
- app/ui/index.html: login page, email in navbar, JWT session in localStorage
- deployments/k8s/deployment.yaml: v0.1.16, NUBES_ENDPOINT env
- doc/decisions/resource-limits-plan.md: 20 vulnerabilities audit
- Fix: /ui/api/auth moved to subrouter (gorilla/mux PathPrefix conflict)
This commit is contained in:
Naeel
2026-04-10 18:35:19 +03:00
parent 6fb160f8ae
commit b9d434bcc5
9 changed files with 854 additions and 25 deletions
+159
View File
@@ -0,0 +1,159 @@
# Решение: Защита от ресурсного исчерпания (Resource Exhaustion Protection)
**Дата:** 2026-04-10
**Статус:** ПЛАН (на согласовании)
**Автор анализа:** GitHub Copilot (Claude Opus 4.6)
---
## Контекст
Проведён полный аудит shared-sqs на уязвимости типа DoS / resource exhaustion.
Найдено **20 уязвимостей** (2 Critical, 8 High, 8 Medium, 2 Low).
Главная угроза: **один тенант может положить сервис для всех** — через создание огромных очередей, бесконечный long polling, отсутствие валидации размеров.
Кросс-тенантный доступ к данным **невозможен** — изоляция через составной ключ работает корректно.
---
## Найденные уязвимости
### 🔴 CRITICAL
| # | Уязвимость | Где | Как эксплуатировать |
|---|-----------|-----|-------------------|
| 1 | **Публичный Admin API без auth** | `/ui/api/*` | Любой может создавать тенантов, очереди, слать сообщения, удалять данные без токена |
| 2 | **Batch message size bypass** | `send_message_batch.go` | `SendMessageBatchV1` не проверяет размер тела каждого сообщения; 10×256MB = 2.5GB в одном запросе |
### 🟠 HIGH
| # | Уязвимость | Где | AWS лимит |
|---|-----------|-----|-----------|
| 3 | QueueName без валидации | `create_queue.go` | Макс 80 chars, `[a-zA-Z0-9_-]` |
| 4 | WaitTimeSeconds без потолка | `receive_message.go` | 0–20 сек |
| 5 | ReceiveMessageWaitTimeSeconds без потолка | `queue_attributes.go` | 0–20 сек |
| 6 | DelaySeconds без потолка | `queue_attributes.go` | 0–900 сек |
| 7 | VisibilityTimeout без потолка | `queue_attributes.go` | 0–43200 сек |
| 8 | MaxNumberOfMessages без потолка | `receive_message.go` | 1–10 |
| 9 | Message attributes без лимита | `requests.go` | Макс 10, общий размер ≤256KB |
| 10 | Data race в GetQueueUrlV1 | `get_queue_url.go` | Нет RLock перед чтением |
### 🟡 MEDIUM
| # | Уязвимость | Где | Последствие |
|---|-----------|-----|------------|
| 11 | Нет лимита сообщений в очереди | все send handlers | OOM при миллионах сообщений |
| 12 | Нет лимита на создание тенантов | `admin.go` | OOM при 100K тенантов |
| 13 | FIFO group lock без таймаута | `receive_message.go` | Group заблокирован навсегда |
| 14 | Duplicates map без очистки | `models.go` | Утечка памяти |
| 15 | BatchEntryId без валидации длины | `send_message_batch.go` | Раздутые ключи |
| 16 | DeduplicationID без валидации | `send_message_batch.go` | 128 chars макс по AWS |
| 17 | GroupID без валидации | `send_message.go` | 128 chars макс по AWS |
| 18 | Redis serialization без лимита | `redis.go` | Redis OOM при гигантских очередях |
### 🟢 LOW
| # | Уязвимость | Где |
|---|-----------|-----|
| 19 | `{account}` в URL не валидируется | `router.go` |
| 20 | ReceiptHandle не валидируется по формату | `delete_message.go` |
---
## План реализации
### Фаза 1 — КРИТИЧЕСКОЕ (блокирует production)
**Цель:** устранить уязвимости, позволяющие анонимную атаку.
| Задача | Файлы | Сложность | Что делать |
|--------|-------|-----------|-----------|
| 1.1 Защитить публичный API | `admin.go`, `router.go` | Низкая | Добавить опцию: либо bearer auth на `/ui/api/*`, либо убрать write-эндпоинты из public routes, оставив только read |
| 1.2 Валидация размера в batch | `send_message_batch.go` | Низкая | Добавить проверку `len(entry.MessageBody) > queue.MaximumMessageSize` в цикле по entries |
| 1.3 RLock в GetQueueUrl | `get_queue_url.go` | Низкая | Обернуть чтение SyncQueues в `RLock()/RUnlock()` |
### Фаза 2 — AWS-совместимые лимиты (валидация параметров)
**Цель:** привести параметры к стандартам AWS SQS. Создать единый пакет `validation`.
| Задача | Файлы | Что делать |
|--------|-------|-----------|
| 2.1 Валидация QueueName | `create_queue.go` | Макс 80 chars, regex `^[a-zA-Z0-9_-]+(\.fifo)?$` |
| 2.2 WaitTimeSeconds cap | `receive_message.go` | Clamp к 0–20 |
| 2.3 ReceiveMessageWaitTimeSeconds cap | `queue_attributes.go` | Clamp к 0–20 |
| 2.4 DelaySeconds cap | `queue_attributes.go` | Clamp к 0–900 |
| 2.5 VisibilityTimeout cap | `queue_attributes.go` | Clamp к 0–43200 |
| 2.6 MaxNumberOfMessages cap | `receive_message.go` | Clamp к 1–10 |
| 2.7 Message attributes limit | `requests.go`, `send_message.go`, `send_message_batch.go` | Макс 10 атрибутов, общий размер ≤256KB |
| 2.8 DeduplicationID/GroupID length | `send_message.go`, `send_message_batch.go` | Макс 128 chars каждый |
### Фаза 3 — Per-tenant resource limits
**Цель:** один тенант не может выжрать все ресурсы.
| Задача | Файлы | Что делать |
|--------|-------|-----------|
| 3.1 Макс сообщений в очереди | `send_message.go`, `send_message_batch.go` | Лимит per queue (напр. 120,000 — как AWS standard) |
| 3.2 Макс суммарный размер per tenant | `tenant_helpers.go` | Счётчик bytes per tenant; отказ при превышении |
| 3.3 Rate limiting per tenant | Новый middleware | Token bucket или sliding window; напр. 300 req/sec per tenant |
| 3.4 Макс тенантов в системе | `admin.go`, `tenant_store.go` | Глобальный лимит (конфигурируемый) |
| 3.5 HTTP request body size limit | `router.go` или middleware | `http.MaxBytesReader` — напр. 1MB на запрос |
### Фаза 4 — Стабильность и очистка
**Цель:** утечки памяти и deadlock-сценарии.
| Задача | Файлы | Что делать |
|--------|-------|-----------|
| 4.1 FIFO group lock timeout | `models.go`, `receive_message.go` | Таймаут = VisibilityTimeout очереди. Горутина чистит expired locks |
| 4.2 Duplicates map cleanup | `models.go` | Горутина-ticker каждые 30 сек, удаляет записи старше 5 мин |
| 4.3 Redis size guard | `redis.go` | Не сохранять в Redis если `len(data) > 50MB`; логировать warning |
| 4.4 `{account}` валидация | `router.go` или handlers | Проверять что `{account}` == tenant.ID из context |
---
## Порядок действий (рекомендация)
```
Фаза 1 (Critical) → тесты → деплой
↓
Фаза 2 (AWS limits) → тесты → деплой
↓
Фаза 3 (Per-tenant) → тесты → деплой
↓
Фаза 4 (Stability) → тесты → деплой
```
Каждая фаза — отдельный коммит/PR с тестами.
---
## Что НЕ делаем (и почему)
| Отброшено | Причина |
|----------|---------|
| WAF / Nginx rate limit | Overkill для текущего масштаба; лучше in-app |
| Подпись проверки (HMAC) | Сервис эмулирует SQS — подпись не проверяется by design (как LocalStack) |
| Шифрование сообщений at rest | Redis на localhost, не критично на этом этапе |
| Горизонтальное масштабирование | Другая задача; лимиты работают и в single-pod |
---
## AWS SQS лимиты (справка)
| Параметр | AWS лимит |
|----------|----------|
| Queue name length | 80 chars |
| Queue name chars | `[a-zA-Z0-9_-]` (+ `.fifo` суффикс) |
| Message body | 256 KB |
| Message attributes | 10, общий размер ≤ 256 KB |
| MaxNumberOfMessages | 1–10 |
| WaitTimeSeconds | 0–20 |
| DelaySeconds | 0–900 |
| VisibilityTimeout | 0–43200 (12 часов) |
| MessageRetentionPeriod | 60–1,209,600 (14 дней) |
| Messages per queue | ~120,000 in-flight |
| DeduplicationID | 128 chars |
| GroupID | 128 chars |
| Batch size | 10 entries |
+12 -1
View File
@@ -12,12 +12,23 @@
## Next Steps
- [ ] Migration guide: как обновить сервис на v0.1.14
- [ ] Собрать Docker образ v0.1.15, задеплоить, проверить JWT auth flow
- [ ] Resource limits (Фаза 1-4 из doc/decisions/resource-limits-plan.md)
- [ ] Migration guide: как обновить сервис на v0.1.15
- [ ] Load testing shared-sqs (текущая реализация имеет глобальный мьютекс)
- [ ] DLQ (Dead Letter Queue) поддержка
- [ ] Long Polling оптимизация
- [ ] Rate limiting per tenant
### v0.1.15 (2026-04-10) — IN PROGRESS
- ✅ JWT auth через nubes API (deck-api-test.ngcloud.ru)
- ✅ Login page в UI (ввод токена → валидация → auto-provisioning tenant)
- ✅ Email пользователя в navbar
- ✅ /ui/api/* защищены JWT middleware (больше не публичные)
- ✅ TenantID совместим с sless namespace: sless-{SHA256(sub)[:8]}
- ✅ Сессия в localStorage (token + email)
- [ ] Docker build + deploy + E2E test
## Known Limitations
1. **Глобальный мьютекс** — SyncQueues.Lock() на весь сервис. Performance bottleneck.
+262
View File
@@ -88,3 +88,265 @@ Latency (ok) : min=0.023s median=0.044s p95=0.332s max=3.920s
- H2 и H1 работают одинаково хорошо
**Вывод: харбор стабилен.** Апгрейд ресурсов полностью устранил проблему с зависаниями. Harbor пригоден для использования как registry для kaniko push.
---
## Сессия 2 — Анализ защиты от ресурсного исчерпания
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Полный аудит shared-sqs на уязвимости типа DoS / resource exhaustion.
Создать план защиты: один тенант не должен мочь положить сервис для всех.
### Ход анализа
#### Что проверял
Все SQS handlers (`app/gosqs/*.go`), admin API (`app/admin/admin.go`), модели (`app/models/`), persistence (`app/persistence/redis.go`), auth (`app/auth/`), tenant store (`app/tenant/`).
#### Гипотезы и что нашёл
**Гипотеза 1: кросс-тенантный доступ возможен?**
→ НЕТ. Изоляция через составной ключ `{accessKey}:{queueName}` работает корректно. Все handlers извлекают tenant из context (auth middleware), строят ключ через `tenantQueueKey()`. Обойти нельзя — accessKey проверяется в middleware, ключ строится на стороне сервера.
**Гипотеза 2: можно ли через URL path `/{account}/` получить доступ к чужим данным?**
→ НЕТ. `{account}` из URL НЕ используется для поиска очереди. Handler всегда берёт tenant из context (middleware), игнорируя path segment. Но `{account}` не валидируется — можно подставить чужой ID, что загрязнит логи.
**Гипотеза 3: DoS через неограниченное создание ресурсов?**
→ ДА. Критическая проблема:
- QueueName: нет валидации длины/символов (AWS ограничивает 80 chars, [a-zA-Z0-9_-])
- Messages per queue: без лимита
- Message body size: проверяется ТОЛЬКО в SendMessageV1, НЕ проверяется в SendMessageBatchV1
- Message attributes: без лимита на количество и размер (AWS: макс 10 атрибутов, общий размер ≤256KB)
- Tenant creation: без лимита (и PUBLIC API `/ui/api/tenants` без auth!)
- Long polling: WaitTimeSeconds без верхней границы (AWS: макс 20 сек)
**Гипотеза 4: можно ли исчерпать Redis?**
→ ДА. `SaveQueue()` сериализует всю очередь (включая ВСЕ сообщения) в один JSON → один ключ в Redis HASH. Очередь с 1M сообщений = один JSON ~1GB.
**Гипотеза 5: FIFO lock можно заблокировать навсегда?**
→ ДА. `LockGroup()` вызывается при ReceiveMessage. Если клиент получил сообщение и не вызвал DeleteMessage, group ID заблокирован до перезагрузки. Нет таймаута на lock.
**Гипотеза 6: data race в handlers?**
→ ДА. `GetQueueUrlV1` читает `SyncQueues.Queues[key]` без RLock — race condition при concurrent write.
**Гипотеза 7: Duplicates map утечка памяти?**
→ ДА. `Duplicates map[string]time.Time` в FIFO очередях растёт без ограничений. AWS очищает через 5 минут.
#### Что отбросил
- Атака через ReceiptHandle — формат `uuid#uuid`, перебор нереален (2^244)
- Атака через Authorization header — парсится корректно, плохой формат = 403
- Redis injection — go-redis использует протокол RESP, не строки; инъекция невозможна
### Найденные уязвимости (20 штук)
#### CRITICAL (2)
1. **Публичный Admin API** — `/ui/api/*` без auth, полный доступ к CRUD тенантов/очередей/сообщений
2. **Batch message size bypass** — SendMessageBatchV1 не проверяет размер тела каждого сообщения
#### HIGH (8)
3. QueueName без валидации длины/символов
4. WaitTimeSeconds без верхней границы (должно быть ≤20)
5. ReceiveMessageWaitTimeSeconds атрибут без верхней границы
6. DelaySeconds без верхней границы (AWS макс 900)
7. VisibilityTimeout без верхней границы (AWS макс 43200)
8. MaxNumberOfMessages без верхней границы (AWS макс 10)
9. Message attributes: без лимита на количество и размер
10. Data race в GetQueueUrlV1 (нет RLock)
#### MEDIUM (8)
11. Нет лимита на количество сообщений в очереди
12. Нет лимита на создание тенантов
13. FIFO group lock без таймаута
14. Duplicates map без очистки
15. BatchEntryId без валидации длины
16. DeduplicationID без валидации длины (AWS макс 128)
17. GroupID без валидации длины (AWS макс 128)
18. Redis serialization без ограничения размера
#### LOW (2)
19. `{account}` в URL не валидируется
20. ReceiptHandle не валидируется по формату перед поиском
### План защиты — приоритизация
Принцип: начать с самого опасного и дешёвого в реализации.
**Фаза 1 — Критическое (блокирует production)**
1. Убрать или защитить `/ui/api/*` маршруты
2. Добавить валидацию размера тела в SendMessageBatchV1
3. Добавить RLock в GetQueueUrlV1
**Фаза 2 — AWS-совместимые лимиты (валидация параметров)**
4. QueueName: макс 80 chars, regex `^[a-zA-Z0-9_-]+(.fifo)?$`
5. WaitTimeSeconds: 0-20
6. ReceiveMessageWaitTimeSeconds: 0-20
7. DelaySeconds: 0-900
8. VisibilityTimeout: 0-43200
9. MaxNumberOfMessages: 1-10
10. Message attributes: макс 10, общий размер ≤256KB
11. DeduplicationID: макс 128 chars
12. GroupID: макс 128 chars
**Фаза 3 — Per-tenant resource limits**
13. Макс сообщений в очереди (per queue, напр. 100K)
14. Макс общий размер сообщений per tenant (напр. 1GB)
15. Rate limiting per tenant (напр. 100 req/sec)
16. Макс тенантов в системе (глобальный лимит)
**Фаза 4 — Стабильность**
17. FIFO group lock timeout (= VisibilityTimeout)
18. Duplicates map cleanup (goroutine, TTL 5 мин)
19. Redis: ограничить размер сериализации / разбить на chunks
20. `{account}` в URL: валидировать = tenant ID из context
---
## Сессия 3 — Контроль доступа через nubes JWT
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Заменить текущий auth (AccessKey/SecretKey per tenant → in-memory TenantStore) на JWT-токен nubes.
Пользователь вводит токен в UI → токен валидируется через `https://deck-api-test.ngcloud.ru/api/v1`.
Email из токена показывается в UI справа вверху.
### Разведка sless проекта
Изучил `~/terra/sless/` — соседний проект, где эта схема уже работает.
#### Структура JWT токена nubes (реальный пример):
```json
{
"iss": "auth-api",
"sub": "0199e325-1cdf-7cda-9319-e5302a85e291", // UUID пользователя
"exp": 1786932675,
"email": "tazet@narod.ru", // Email — показывать в UI
"email_verified": false,
"name": "",
"preferred_username": "",
"realm_access": {"roles": null},
"resource_access": {"account": {"roles": null}}
}
```
#### Как sless это делает:
1. **JWT parsing** (`client.go`): `SubFromJWT(token)` → декодирует JWT payload → возвращает `sub` (UUID)
2. **Namespace** (`client.go`): `NamespaceFromSub(sub)` → `SHA256(sub)[:8]` → `"sless-{16hex}"`
3. **Валидация** (`client.go`): `PingNubesAPI(endpoint, token)` → GET к `deck-api-test.ngcloud.ru/api/v1` с Bearer → 401/403 = отклонён
4. **Auth middleware** (`middleware/auth.go`): проверяет `Authorization: Bearer <token>` → в тестовом режиме принимает любую строку
#### Ключевые решения sless:
- Подпись JWT НЕ проверяется (нет JWKS endpoint nubes) — "trusted perimeter"
- Валидация токена = запрос к nubes API (PingNubesAPI) — если API вернул не 401/403, значит токен живой
- sub пользователя (UUID) хешируется для namespace — чтобы не показывать реальный ID наружу
### Размышления для SQS-service
**Вопрос 1: нужен ли namespace из хеша для SQS?**
Пользователь сказал "для SQS самого как очереди может и не надо". И правда:
- В sless namespace нужен для k8s: каждый пользователь = свой namespace с функциями/подами
- В SQS очереди живут в in-memory map, изоляция через составной ключ `{accessKey}:{queueName}`
- НО в общей конфигурации IoT + sless + funcs + SQS — единый namespace пользователя нужен
**Решение**: вычислять namespace НО использовать его как tenantID (а не k8s namespace).
Формула та же: `SHA256(sub)[:8]` → `"sless-{16hex}"` — совместимость с sless.
**Вопрос 2: что делать с текущим TenantStore (AccessKey/SecretKey)?**
Текущая система: admin создаёт тенанта → получает credentials → вводит в AWS CLI.
Новая система: пользователь вводит JWT → auto-provisioning тенанта.
Варианты:
- A) Полностью заменить → ломает существующих тестовых пользователей
- B) Добавить JWT как второй путь auth → оба работают
- C) JWT через UI → auto-create tenant с AccessKey → AWS CLI использует AccessKey
Вариант C самый логичный: JWT auth в UI/admin, AccessKey auth в SQS API (AWS SDK совместимость).
**Вопрос 3: Email в UI?**
Из JWT: `claims.email` → показать в правом верхнем углу UI.
### План (предварительный, ждём подтверждения)
1. Добавить JWT-парсинг (аналог sless SubFromJWT + EmailFromJWT)
2. Добавить PingNubesAPI для валидации токена
3. UI: окно ввода токена → при вводе → автоматически создаётся tenant
4. UI: показать email в правом верхнем углу
5. Совместимость: SQS API по-прежнему через AccessKey (AWS SDK), JWT — только для UI/admin
### Реализация (выполнено)
#### Новые файлы:
- `app/auth/jwt.go` — ParseJWTClaims, TenantIDFromSub (SHA256 совместимый с sless), PingNubesAPI
#### Изменённые файлы:
- `app/tenant/tenant_store.go`:
- Добавлены поля `NubesSub`, `Email` в Tenant struct
- Третий индекс `bySub` в TenantStore
- Метод `GetBySub(sub)` для поиска по JWT sub
- Метод `CreateFromJWT(tenantID, sub, email, maxQueues)` — идемпотентный auto-provisioning
- Все операции (Create, Delete, LoadTenant) обновлены для bySub индекса
- `app/admin/admin.go`:
- Добавлен `nubesEndpoint` в Handler (из env `NUBES_ENDPOINT`, default `https://deck-api-test.ngcloud.ru/api/v1`)
- `POST /ui/api/auth` — публичный endpoint: принимает JWT → валидирует через nubes → auto-provision tenant → ответ с email/credentials
- `jwtMiddleware` — middleware для защиты остальных /ui/api/* endpoints
- RegisterPublicRoutes теперь использует jwtMiddleware (кроме /ui/api/auth)
- `app/ui/index.html`:
- Добавлена login page с вводом JWT токена
- Email отображается в navbar справа вверху
- Сессия сохраняется в localStorage (token + email)
- Все API запросы теперь с `Authorization: Bearer <jwt>` header
- При 401/403 → автоматический выход на login page
- Кнопка "Выйти" очищает сессию
#### Результат компиляции:
`go build ./...` — PASS (без ошибок).
`go test ./...` — pre-existing failures (missing fixtures, Topics) — не связаны с моими изменениями.
#### Архитектурное решение:
- JWT auth → ТОЛЬКО для UI console (/ui/api/*)
- SQS API → по-прежнему через AccessKey в AWS Authorization header (совместимость с AWS SDK)
- TenantID из JWT = `sless-{SHA256(sub)[:8]}` — идентичен sless namespace → единый пользователь во всех сервисах
---
## Сессия 4
# Agent: GitHub Copilot (Claude Opus 4.6)
### Задача
Дебаг 404 на POST /ui/api/auth после деплоя v0.1.15
### Диагностика
1. **Первая гипотеза**: gorilla/mux route ordering — `r.PathPrefix("/ui")` static handler перехватывает `/ui/api/auth`.
- Перенёс `/ui/api/auth` на subrouter вместо root router HandleFunc.
- Собрал v0.1.16, задеплоил → **всё ещё 404**
2. **Тест изнутри пода**: `kubectl exec ... wget POST /ui/api/auth` → **401 Unauthorized** (маршрут работает!).
- Значит проблема НЕ в коде, а в прохождении через ingress.
3. **Тест через port-forward**: `curl POST http://localhost:14100/ui/api/auth` → **JSON ответ** (работает!).
- Подтверждение: код верный, ingress ломает.
4. **Ключевое открытие — два IP**:
- DNS `qu.kube5s.ru` → `185.247.187.151`
- Ingress в kubectl → `185.247.187.147`
- Тест напрямую на `.147`: POST auth → **работает** (JSON)
- Тест напрямую на `.151`: POST auth → **404**
5. **Причина**: kubectl был подключён к СТАРОМУ кластеру (`.147`), а DNS `qu.kube5s.ru` указывал на НОВЫЙ кластер `iot-naeel` (`.151`). Все деплои шли не туда.
### Решение
- Обновил kubeconfig на VM → новый кластер `iot-naeel` (API: `185.247.187.149:6443`, ingress: `185.247.187.151`)
- Задеплоил v0.1.16 в правильный кластер
- **Результат**: POST `/ui/api/auth` → 401 (корректный ответ), GET `/ui/api/health` → 401 (middleware работает), `/health` → OK
### Изменения в коде (v0.1.16)
- `app/admin/admin.go`: `/ui/api/auth` перенесён на subrouter (вместо root router HandleFunc) — исключает конфликт с PathPrefix в gorilla/mux. jwtMiddleware пропускает `/auth` path.
- `app/router/router.go`: комментарий о порядке регистрации routes.
### Redis
В новом кластере Redis подключается корректно: `rfrm-redisk8s.UUID.svc.cluster.local:6379`.
Загружено 6 тенантов, 31 очередь из Redis — данные мигрированы.
### Вывод
Проблема была инфраструктурная (два кластера), не программная. Код JWT auth работал с первой попытки.