Задачи 011-024: доработки по ревью кодовой базы и исправление находок

Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-26 21:33:50 +03:00
1 parent cd09ef0805
commit 13e17fbb47
57 files changed
+1748 -166

No files matched your search

+37 -3
View File
@@ -1,4 +1,5 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from pathlib import Path
@@ -10,20 +11,26 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from app.api.v1 import auth, journal, overview, prefixes, refs, users
from app.config import settings
from app.config import settings, validate_secrets
from app.db import SessionLocal
from app.models import DeviceType, Role, User
from app.request_context import RequestContextMiddleware
from app.rotation import rotation_loop
from app.security import hash_password
validate_secrets() # приложение не стартует с небезопасной конфигурацией (изменение 017)
log = logging.getLogger("ipam")
DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"]
def seed():
with SessionLocal() as db:
if not db.scalar(select(User.id).limit(1)) and settings.admin_password:
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
if not db.scalar(select(User.id).limit(1)):
if settings.admin_password:
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
else:
log.warning("В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя")
if not db.scalar(select(DeviceType.id).limit(1)):
db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES)
db.commit()
@@ -37,8 +44,35 @@ async def lifespan(_: FastAPI):
task.cancel()
CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'"
DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") # Swagger UI грузит ресурсы с CDN — CSP для него не ставим
class SecurityHeadersMiddleware:
"""ASGI-middleware: заголовки безопасности на все ответы (изменение 023)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
docs = scope["path"].startswith(DOCS_PATHS)
async def send_with_headers(message):
if message["type"] == "http.response.start":
extra = [(b"x-content-type-options", b"nosniff"), (b"referrer-policy", b"no-referrer")]
if not docs:
extra += [(b"content-security-policy", CSP.encode()), (b"x-frame-options", b"DENY")]
message["headers"] = [*message.get("headers", []), *extra]
await send(message)
await self.app(scope, receive, send_with_headers)
app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
app.add_middleware(RequestContextMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
api = APIRouter(prefix="/api/v1")
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router):