Files
ayurishchevandClaude Opus 5.5 c4079724c5 Безопасность: проверка секретов, блокировка перебора входа, безопасные редиректы
Безопасность, пункты 1–4 ревью (docs/changes/021):
- приложение не стартует со слабыми секретами: API_TOKEN и SESSION_SECRET
  пустые, change-me или короче 32 символов, ADMIN_PASSWORD короче 12,
  невалидный SECRET_KEY; значения секретов в ошибку не попадают;
- вход в UI: 5 неверных попыток за 10 минут блокируют IP на 10 минут
  (429, пароль не проверяется); неверный пароль — 401; событие
  auth.locked пишется один раз, журнал не засыпается перебором;
- SESSION_COOKIE_SECURE — флаг Secure у cookie сессии (по умолчанию выкл.);
- _safe_next: редирект только на локальный путь (/ui/move, удаление бэкапов);
- .env.bak-* в .gitignore.

Тесты: 28 из 28. Стенд: change-me → 401, новый токен → 200, отказ старта
со слабым токеном, блокировка входа и редиректы проверены, боевые данные
не изменены. Ручная проверка UI пользователем на момент коммита не
подтверждена.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:41:58 +03:00

72 lines
2.7 KiB
Python

import asyncio
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from botocore.exceptions import BotoCoreError, ClientError
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app import process_lock
from app.api import v1
from app.config import get_settings, insecure_settings
from app.db import init_db
from app.services import backups, jobs, poller, rotation
from app.ui import routes as ui
@asynccontextmanager
async def lifespan(app: FastAPI):
problems = insecure_settings(get_settings())
if problems:
raise RuntimeError("Небезопасная конфигурация: " + "; ".join(problems) + "; см. README «Настройки»")
process_lock.acquire(get_settings().database_url) # один процесс на файловую БД (--workers 1)
try:
init_db()
jobs.fail_stale_jobs()
task = asyncio.create_task(poller.run()) if get_settings().poll_interval > 0 else None
reconcile = asyncio.create_task(backups.reconcile()) # связать файлы бакета с метаданными (best-effort)
rotator = asyncio.create_task(rotation.run()) # ротация журнала по настройкам
yield
for t in (task, reconcile, rotator):
if t:
t.cancel()
with suppress(asyncio.CancelledError):
await t
finally:
process_lock.release()
def create_app() -> FastAPI:
app = FastAPI(title="ros_control", lifespan=lifespan)
app.add_middleware(
SessionMiddleware, secret_key=get_settings().session_secret,
same_site="strict", https_only=get_settings().session_cookie_secure,
)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "ui" / "static"), name="static")
app.include_router(v1.router)
app.include_router(ui.router)
@app.exception_handler(ui.LoginRequired)
async def _login(_: Request, e: ui.LoginRequired):
return RedirectResponse("/login", status_code=303)
@app.exception_handler(LookupError)
async def _404(_: Request, e: LookupError):
return JSONResponse({"detail": str(e).strip("'\"")}, status_code=404)
@app.exception_handler(ValueError)
async def _400(_: Request, e: ValueError):
return JSONResponse({"detail": str(e)}, status_code=400)
@app.exception_handler(BotoCoreError)
@app.exception_handler(ClientError)
async def _502(_: Request, e: Exception):
return JSONResponse({"detail": f"S3: {e}"}, status_code=502)
return app
app = create_app()