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
+113 -7
View File
@@ -1,15 +1,18 @@
// app/admin/admin.go
// Admin API handlers for shared-sqs management
// Created: 2026-04-09
// Updated: 2026-04-10 — добавлены endpoints для управления очередями и просмотра сообщений
// Updated: 2026-04-10 — JWT auth через nubes API, auto-provisioning тенантов
package admin
import (
"context"
"encoding/json"
"net/http"
"os"
"strings"
"time"
"shared-sqs/app/auth"
"shared-sqs/app/models"
"shared-sqs/app/tenant"
@@ -33,13 +36,18 @@ func findQueue(tenantAccessKey, queueName string) (string, *models.Queue) {
// Handler — admin API handler, holds TenantStore и admin token
type Handler struct {
store *tenant.TenantStore
adminToken string
store *tenant.TenantStore
adminToken string
nubesEndpoint string // URL nubes API для валидации JWT (напр. https://deck-api-test.ngcloud.ru/api/v1)
}
// NewHandler — создаёт admin handler
func NewHandler(store *tenant.TenantStore, adminToken string) *Handler {
return &Handler{store: store, adminToken: adminToken}
nubesEndpoint := os.Getenv("NUBES_ENDPOINT")
if nubesEndpoint == "" {
nubesEndpoint = "https://deck-api-test.ngcloud.ru/api/v1"
}
return &Handler{store: store, adminToken: adminToken, nubesEndpoint: nubesEndpoint}
}
// RegisterRoutes — регистрирует admin маршруты на переданном router (с bearer auth)
@@ -59,11 +67,16 @@ func (h *Handler) RegisterRoutes(r *mux.Router) {
adminRouter.HandleFunc("/health", h.detailedHealth).Methods("GET")
}
// RegisterPublicRoutes — публичные маршруты для UI console (без auth)
// Дублируют admin API, но доступны без bearer token для удобства демо
// TODO: убрать или заменить на session-auth перед production
// RegisterPublicRoutes — маршруты для UI console.
// POST /ui/api/auth — единственный публичный endpoint (принимает JWT, возвращает session).
// Остальные /ui/api/* — защищены JWT middleware (токен в Authorization header).
// ВАЖНО: все UI API routes на ОДНОМ subrouter PathPrefix("/ui/api") — иначе через nginx ingress
// root-router HandleFunc конфликтует с PathPrefix subrouter (404 на POST).
func (h *Handler) RegisterPublicRoutes(r *mux.Router) {
ui := r.PathPrefix("/ui/api").Subrouter()
// jwtMiddleware пропускает /ui/api/auth — единственный публичный endpoint
ui.Use(h.jwtMiddleware)
ui.HandleFunc("/auth", h.jwtAuth).Methods("POST")
ui.HandleFunc("/health", h.detailedHealth).Methods("GET")
ui.HandleFunc("/tenants", h.listTenants).Methods("GET")
ui.HandleFunc("/tenants", h.createTenant).Methods("POST")
@@ -92,6 +105,99 @@ func (h *Handler) bearerAuthMiddleware(next http.Handler) http.Handler {
})
}
// jwtAuth — POST /ui/api/auth — принимает JWT токен, валидирует через nubes API,
// auto-provision тенанта если не существует, возвращает email + tenant info.
// Это единственный публичный endpoint UI API.
func (h *Handler) jwtAuth(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Token == "" {
jsonErr(w, http.StatusBadRequest, "token is required")
return
}
// Парсим JWT claims
claims, err := auth.ParseJWTClaims(req.Token)
if err != nil {
log.Warnf("jwt auth: parse error: %v", err)
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
return
}
// Валидируем через nubes API
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := auth.PingNubesAPI(ctx, h.nubesEndpoint, req.Token); err != nil {
log.Warnf("jwt auth: nubes rejected token for sub=%s: %v", claims.Sub, err)
jsonErr(w, http.StatusForbidden, "token rejected by cloud API: "+err.Error())
return
}
// Auto-provisioning: создаём тенанта если не существует
tenantID := auth.TenantIDFromSub(claims.Sub)
t, err := h.store.CreateFromJWT(tenantID, claims.Sub, claims.Email, 10)
if err != nil {
log.Errorf("jwt auth: failed to create tenant: %v", err)
jsonErr(w, http.StatusInternalServerError, "failed to provision tenant")
return
}
log.Infof("jwt auth: authenticated sub=%s email=%s tenant=%s", claims.Sub, claims.Email, t.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"email": claims.Email,
"tenant_id": t.ID,
"access_key": t.AccessKey,
"secret_key": t.SecretKey,
"max_queues": t.MaxQueues,
"token": req.Token, // возвращаем для использования в последующих запросах
})
}
// jwtMiddleware — middleware для /ui/api/* endpoints.
// Пропускает /ui/api/auth (публичный endpoint авторизации).
// Проверяет Authorization: Bearer <jwt> заголовок.
// Парсит JWT, проверяет что sub соответствует существующему тенанту.
// НЕ вызывает PingNubesAPI повторно — токен уже провалидирован при /ui/api/auth.
func (h *Handler) jwtMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// /ui/api/auth — публичный endpoint, пропускаем без проверки JWT
if strings.HasSuffix(r.URL.Path, "/auth") {
next.ServeHTTP(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
jsonErr(w, http.StatusUnauthorized, "authorization required")
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
jsonErr(w, http.StatusUnauthorized, "invalid authorization format")
return
}
token := parts[1]
claims, err := auth.ParseJWTClaims(token)
if err != nil {
jsonErr(w, http.StatusUnauthorized, "invalid token: "+err.Error())
return
}
// Проверяем что тенант существует (был создан при /ui/api/auth)
_, ok := h.store.GetBySub(claims.Sub)
if !ok {
jsonErr(w, http.StatusForbidden, "tenant not found — authenticate first via POST /ui/api/auth")
return
}
next.ServeHTTP(w, r)
})
}
// createTenantRequest — тело запроса POST /admin/tenants
type createTenantRequest struct {
Name string `json:"name"`
+99
View File
@@ -0,0 +1,99 @@
// Изменено: 2026-04-10
// jwt.go — JWT-парсинг и валидация через nubes API (deck-api).
// Извлекает sub (UUID пользователя) и email из JWT claims.
// Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён.
// Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter).
package auth
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// JWTClaims — минимальный набор claims из nubes JWT.
// Не все поля заполнены у каждого пользователя — email может быть пустым.
type JWTClaims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Exp int64 `json:"exp"`
Iss string `json:"iss"`
}
// ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims.
// Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp.
// Подпись НЕ проверяется — валидация через PingNubesAPI.
func ParseJWTClaims(token string) (*JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts))
}
// JWT использует base64url без padding — добавляем
payload := parts[1]
switch len(payload) % 4 {
case 2:
payload += "=="
case 3:
payload += "="
}
decoded, err := base64.URLEncoding.DecodeString(payload)
if err != nil {
// Пробуем StdEncoding на случай нестандартного токена
decoded, err = base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, fmt.Errorf("decode JWT payload: %w", err)
}
}
var claims JWTClaims
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, fmt.Errorf("parse JWT claims: %w", err)
}
if claims.Sub == "" {
return nil, fmt.Errorf("JWT missing 'sub' claim")
}
if claims.Exp > 0 && claims.Exp < time.Now().Unix() {
return nil, fmt.Errorf("JWT token expired")
}
return &claims, nil
}
// TenantIDFromSub — вычисляет tenant ID из JWT subject (sub claim).
// Алгоритм совместим с sless: SHA256(sub) → первые 8 байт → hex → "sless-{16hex}".
// Длина: 6 + 16 = 22 символа. Детерминирован, необратим.
// Почему "sless-" а не "ssq-": единый namespace для всех сервисов (IoT, funcs, SQS).
func TenantIDFromSub(sub string) string {
hash := sha256.Sum256([]byte(sub))
return fmt.Sprintf("sless-%x", hash[:8])
}
// PingNubesAPI — проверяет валидность токена запросом к nubes API.
// endpoint — базовый URL (например "https://deck-api-test.ngcloud.ru/api/v1").
// Логика: 401/403 → токен отклонён. Ошибка соединения → API недоступен.
// Любой другой HTTP статус → токен принят.
func PingNubesAPI(ctx context.Context, endpoint, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("build nubes ping request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("nubes API unreachable at %s: %w", endpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("nubes API rejected token (HTTP %d)", resp.StatusCode)
}
return nil
}
+3 -1
View File
@@ -33,10 +33,12 @@ func New(tenantStore *tenant.TenantStore, adminToken string) http.Handler {
adminHandler := admin.NewHandler(tenantStore, adminToken)
adminHandler.RegisterRoutes(r)
// UI public API — без auth, для встроенной console
// UI public API — JWT auth, для встроенной console
// ВАЖНО: RegisterPublicRoutes ПЕРЕД static handler — иначе PathPrefix("/ui") перехватит /ui/api/*
adminHandler.RegisterPublicRoutes(r)
// UI console — встроенный SPA, публичный доступ
// НЕ ловит /ui/api/* — mux сначала проверит более специфичные маршруты выше
r.PathPrefix("/ui").Handler(http.StripPrefix("/ui", ui.Handler()))
// SQS API — tenant auth middleware оборачивает каждый handler отдельно.
+81 -2
View File
@@ -16,21 +16,24 @@ import (
// Tenant — модель тенанта shared-sqs.
// AccessKey используется как идентификатор в AWS Authorization header.
type Tenant struct {
ID string // уникальный идентификатор тенанта (t-<hex>)
ID string // уникальный идентификатор тенанта (sless-<hex> из JWT sub, или t-<hex> для legacy)
Name string // человекочитаемое имя
AccessKey string // аналог AWS AccessKeyId (SSAK-<hex>)
SecretKey string // аналог AWS SecretAccessKey (64 hex chars)
MaxQueues int // лимит очередей (0 = безлимит)
CreatedAt time.Time
Active bool
NubesSub string `json:"nubes_sub,omitempty"` // JWT sub claim (UUID пользователя nubes). Пусто для legacy тенантов.
Email string `json:"email,omitempty"` // Email из JWT. Для отображения в UI.
}
// TenantStore — потокобезопасное in-memory хранилище тенантов.
// Два индекса позволяют быстро искать как по ID (admin API), так и по AccessKey (auth middleware).
// Три индекса: по ID (admin API), по AccessKey (auth middleware), по NubesSub (JWT auth).
type TenantStore struct {
mu sync.RWMutex
byID map[string]*Tenant
byAccessKey map[string]*Tenant
bySub map[string]*Tenant // индекс по NubesSub (JWT sub claim)
}
// NewTenantStore — создаёт пустое хранилище тенантов.
@@ -38,6 +41,7 @@ func NewTenantStore() *TenantStore {
return &TenantStore{
byID: make(map[string]*Tenant),
byAccessKey: make(map[string]*Tenant),
bySub: make(map[string]*Tenant),
}
}
@@ -69,6 +73,9 @@ func (s *TenantStore) Create(name string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
}
s.mu.Unlock()
// Сохраняем в Redis асинхронно — сериализуем здесь, вне мьютекса
@@ -139,6 +146,9 @@ func (s *TenantStore) Delete(id string) bool {
}
delete(s.byID, t.ID)
delete(s.byAccessKey, t.AccessKey)
if t.NubesSub != "" {
delete(s.bySub, t.NubesSub)
}
s.mu.Unlock()
// Удаляем из Redis асинхронно
@@ -153,9 +163,78 @@ func (s *TenantStore) LoadTenant(t *Tenant) {
s.mu.Lock()
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
if t.NubesSub != "" {
s.bySub[t.NubesSub] = t
}
s.mu.Unlock()
}
// GetBySub — поиск тенанта по NubesSub (JWT sub claim).
// Используется при JWT-авторизации через UI.
func (s *TenantStore) GetBySub(sub string) (*Tenant, bool) {
s.mu.RLock()
t, ok := s.bySub[sub]
s.mu.RUnlock()
return t, ok
}
// CreateFromJWT — auto-provisioning тенанта из JWT claims.
// ID = TenantIDFromSub(sub) — совместим с sless namespace.
// Если тенант с таким sub уже существует — возвращает его (идемпотентно).
func (s *TenantStore) CreateFromJWT(tenantID, sub, email string, maxQueues int) (*Tenant, error) {
s.mu.Lock()
// Идемпотентность: если тенант с таким sub уже есть — возвращаем
if existing, ok := s.bySub[sub]; ok {
// Обновляем email если изменился
if email != "" && existing.Email != email {
existing.Email = email
}
s.mu.Unlock()
return existing, nil
}
accessKey, err := generateAccessKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate access key: %w", err)
}
secretKey, err := generateSecretKey()
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("generate secret key: %w", err)
}
// Имя тенанта — email или sub (если email пустой)
name := email
if name == "" {
name = sub
}
t := &Tenant{
ID: tenantID,
Name: name,
AccessKey: accessKey,
SecretKey: secretKey,
MaxQueues: maxQueues,
CreatedAt: time.Now().UTC(),
Active: true,
NubesSub: sub,
Email: email,
}
s.byID[t.ID] = t
s.byAccessKey[t.AccessKey] = t
s.bySub[t.NubesSub] = t
s.mu.Unlock()
// Сохраняем в Redis
if data, err := json.Marshal(t); err == nil {
persistence.SaveTenantRaw(t.ID, data)
}
return t, nil
}
// List — список всех тенантов (для admin GET /tenants).
func (s *TenantStore) List() []*Tenant {
s.mu.RLock()
+121 -12
View File
@@ -3,9 +3,9 @@
app/ui/index.html
SQS Console — веб-интерфейс для shared-sqs (Nubes branding)
Created: 2026-04-10
Updated: 2026-04-10 — queue CRUD (create/delete) + message peek/send/purge
Updated: 2026-04-10 — JWT auth через nubes token, email в navbar, auto-provisioning
Vanilla HTML/CSS/JS SPA. Встраивается через go:embed.
Режим: Публичный UI API без авторизации (демо). Все данные in-memory.
Режим: JWT авторизация через nubes API. Пользователь вводит токен → валидация → сессия.
-->
<html lang="ru">
<head>
@@ -324,15 +324,33 @@ td.msg-expand { padding: 0 !important; border-bottom: 1px solid var(--border); }
</head>
<body>
<!-- ===== APP SHELL (всегда видим, без логина) ===== -->
<div id="app">
<!-- ===== LOGIN PAGE ===== -->
<div id="login-page" class="login-page">
<div class="login-box">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<h1>SQS CONSOLE</h1>
<div class="form-group">
<label for="login-token">API Token (nubes JWT)</label>
<input id="login-token" type="password" placeholder="eyJhbGciOiJSUzI1NiIs...">
</div>
<div id="login-error" class="login-error"></div>
<button class="btn btn-primary" style="width:100%" onclick="doLogin()">Войти</button>
<p style="font-size:12px;color:var(--text-secondary);margin-top:16px">
Токен можно получить в панели управления облаком Nubes
</p>
</div>
</div>
<!-- ===== APP SHELL (показывается после авторизации) ===== -->
<div id="app" class="hidden">
<nav class="navbar">
<div class="navbar-brand">
<img src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes">
<span>SQS CONSOLE</span>
</div>
<div class="navbar-user">
<span>admin</span>
<span id="user-email" style="color:var(--accent)"></span>
<button class="btn-logout" onclick="doLogout()">Выйти</button>
</div>
</nav>
<div class="container">
@@ -447,23 +465,104 @@ let msgCache = {}; // id → объект сообщения для d
let _sendState = { tenantId: null, queueName: null }; // контекст sendMessage
let _createQueueState = { tenantId: null }; // контекст createQueue
// Session state — JWT auth
let sessionToken = null; // JWT токен для Authorization header
let sessionEmail = null; // Email из JWT для отображения в UI
// ===== INIT =====
// Запуск — сразу показываем dashboard без логина
// Проверяем сохранённый токен в localStorage при загрузке
(function init() {
BASE = window.location.origin;
showDashboard();
const saved = localStorage.getItem('sqs_session');
if (saved) {
try {
const s = JSON.parse(saved);
if (s.token && s.email) {
sessionToken = s.token;
sessionEmail = s.email;
enterApp();
return;
}
} catch(e) { /* corrupted — show login */ }
}
showLogin();
})();
// ===== LOGIN / LOGOUT =====
// showLogin — показывает страницу ввода токена
function showLogin() {
document.getElementById('login-page').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
if (refreshTimer) clearInterval(refreshTimer);
}
// doLogin — отправляет токен на /ui/api/auth, при успехе входит в app
function doLogin() {
const token = document.getElementById('login-token').value.trim();
if (!token) {
document.getElementById('login-error').textContent = 'Введите токен';
return;
}
document.getElementById('login-error').textContent = 'Проверка токена...';
fetch(BASE + '/ui/api/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(r => {
if (!r.ok) return r.json().then(d => { throw new Error(d.error || r.statusText); });
return r.json();
})
.then(data => {
sessionToken = data.token;
sessionEmail = data.email || data.tenant_id;
localStorage.setItem('sqs_session', JSON.stringify({
token: sessionToken,
email: sessionEmail
}));
enterApp();
})
.catch(err => {
document.getElementById('login-error').textContent = 'Ошибка: ' + err.message;
});
}
// doLogout — очищает сессию, показывает login
function doLogout() {
sessionToken = null;
sessionEmail = null;
localStorage.removeItem('sqs_session');
if (refreshTimer) clearInterval(refreshTimer);
showLogin();
}
// enterApp — переключает UI с login на app shell
function enterApp() {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
document.getElementById('user-email').textContent = sessionEmail || '';
showDashboard();
}
// ===== API HELPER =====
// api — выполняет запрос к публичному UI API (без auth)
// api — выполняет запрос к UI API с JWT Authorization header
function api(path, opts = {}) {
const headers = {
'Content-Type': 'application/json',
...(opts.headers || {})
};
if (sessionToken) {
headers['Authorization'] = 'Bearer ' + sessionToken;
}
return fetch(BASE + '/ui/api' + path, {
...opts,
headers: {
'Content-Type': 'application/json',
...(opts.headers || {})
}
headers: headers
}).then(r => {
if (r.status === 401 || r.status === 403) {
// Токен истёк или невалиден — выбросить на login
doLogout();
throw new Error('Session expired — please login again');
}
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
if (r.status === 204) return null;
return r.json();
@@ -927,6 +1026,16 @@ function purgeQueueConfirm(tenantId, queueName) {
})
.catch(err => alert('Ошибка: ' + err.message));
}
// Клавиша Enter на поле токена = нажатие "Войти"
document.addEventListener('DOMContentLoaded', function() {
const tokenInput = document.getElementById('login-token');
if (tokenInput) {
tokenInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') doLogin();
});
}
});
</script>
</body>
</html>
+4 -2
View File
@@ -1,6 +1,6 @@
# deployments/k8s/deployment.yaml
# Deployment shared-sqs — strategy RollingUpdate (теперь возможен т.к. Redis хранит состояние)
# Updated: 2026-04-10 — добавлена Redis persistence, Recreate → RollingUpdate
# Updated: 2026-04-10 — v0.1.16: JWT auth через nubes API, fix route ordering
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -25,7 +25,7 @@ spec:
spec:
containers:
- name: shared-sqs
image: naeel/shared-sqs:v0.1.14
image: naeel/shared-sqs:v0.1.16
ports:
- containerPort: 4100
name: http
@@ -52,6 +52,8 @@ spec:
secretKeyRef:
name: shared-sqs-redis
key: password
- name: NUBES_ENDPOINT
value: "https://deck-api-test.ngcloud.ru/api/v1"
resources:
requests:
memory: "64Mi"
+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` | 020 сек |
| 5 | ReceiveMessageWaitTimeSeconds без потолка | `queue_attributes.go` | 020 сек |
| 6 | DelaySeconds без потолка | `queue_attributes.go` | 0900 сек |
| 7 | VisibilityTimeout без потолка | `queue_attributes.go` | 043200 сек |
| 8 | MaxNumberOfMessages без потолка | `receive_message.go` | 110 |
| 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 к 020 |
| 2.3 ReceiveMessageWaitTimeSeconds cap | `queue_attributes.go` | Clamp к 020 |
| 2.4 DelaySeconds cap | `queue_attributes.go` | Clamp к 0900 |
| 2.5 VisibilityTimeout cap | `queue_attributes.go` | Clamp к 043200 |
| 2.6 MaxNumberOfMessages cap | `receive_message.go` | Clamp к 110 |
| 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 | 110 |
| WaitTimeSeconds | 020 |
| DelaySeconds | 0900 |
| VisibilityTimeout | 043200 (12 часов) |
| MessageRetentionPeriod | 601,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 работал с первой попытки.