fix(api): security, caching, atomic rate limits, url obfuscation
Redis:
- Singleton ConnectionPool (redis.asyncio), 50 connections — не создаём
новое TCP-соединение на каждый HTTP-запрос
Rate limiter:
- Полностью переписан на async/await
- Lua-скрипт _LUA_CHECK_AND_INCR — атомарная проверка+инкремент без race condition
- Lua-скрипт _LUA_ACQUIRE_CONCURRENT — атомарный захват слота задачи
- Старый паттерн INCR→check→DECR удалён (race condition при конкурентных запросах)
Security:
- get_current_user кэширует пользователя в Redis на 5 минут (TTL)
Раньше: SELECT users на каждый HTTP-запрос
Теперь: Redis GET (кэш) → SELECT users (только при промахе)
- hashed_password НЕ кладётся в кэш
- invalidate_user_cache() для сброса при смене тарифа/пароля
- get_ws_user() для WebSocket через ?token=JWT (браузеры не могут
передавать Authorization header при WS-handshake)
WebSocket:
- Добавлена аутентификация (Depends(get_ws_user))
- Проверка ownership задачи ДО accept() соединения
- Чужой task_id → закрытие с кодом 4004
URL obfuscation:
- Task.public_id = secrets.token_urlsafe(16) = 22 случайных base64url символа
- Клиент работает только с public_id, внутренний UUID не раскрывается
- Все роутеры переключены на public_id в WHERE условиях
- TaskResponse больше не возвращает input_data (там minio_key и т.д.)
- Миграция 002_add_task_public_id.py
MinIO:
- Singleton клиент (не создаём новый на каждый upload)
- ensure_bucket() вызывается один раз при старте (lifespan), не на каждый запрос
- Путь uploads/{doc_uuid}{ext} — user_id убран из пути
CORS:
- Убраны wildcard allow_methods/allow_headers (несовместимы с credentials=True)
- Явный список: methods=[GET,POST,DELETE,OPTIONS], headers=[Authorization,Content-Type,Accept]
- Swagger/OpenAPI доступны только в ENVIRONMENT=development
Documents:
- Content-Length проверяется ДО чтения тела (ранняя отбивка больших файлов)
- Повторная проверка реального размера после чтения (защита от поддельного заголовка)
- Используем get_current_verified_user вместо get_current_user (требуем подтверждённый email)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
45
services/api/alembic/versions/002_add_task_public_id.py
Normal file
45
services/api/alembic/versions/002_add_task_public_id.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Добавить public_id в таблицу tasks.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-05-24
|
||||
"""
|
||||
|
||||
import secrets
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import text
|
||||
|
||||
revision = "002"
|
||||
down_revision = "001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Добавляем колонку как nullable — сначала заполним данными, потом сделаем NOT NULL
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("public_id", sa.String(32), nullable=True),
|
||||
)
|
||||
|
||||
# Заполняем существующие строки уникальными public_id
|
||||
conn = op.get_bind()
|
||||
tasks = conn.execute(text("SELECT id FROM tasks")).fetchall()
|
||||
for (task_id,) in tasks:
|
||||
conn.execute(
|
||||
text("UPDATE tasks SET public_id = :pid WHERE id = :id"),
|
||||
{"pid": secrets.token_urlsafe(16), "id": task_id},
|
||||
)
|
||||
|
||||
# Делаем NOT NULL и добавляем уникальный индекс
|
||||
op.alter_column("tasks", "public_id", nullable=False)
|
||||
op.create_unique_constraint("uq_tasks_public_id", "tasks", ["public_id"])
|
||||
op.create_index("ix_tasks_public_id", "tasks", ["public_id"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tasks_public_id", table_name="tasks")
|
||||
op.drop_constraint("uq_tasks_public_id", "tasks", type_="unique")
|
||||
op.drop_column("tasks", "public_id")
|
||||
Reference in New Issue
Block a user