refactor: isolate submission rate limiting

This commit is contained in:
ik
2026-09-12 21:19:10 +07:00
parent e48b9ef876
commit 31bbc15535
4 changed files with 62 additions and 39 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ Production release отделяет Alembic от runtime: одноразовый
Тяжёлый production bootstrap вынесен в отдельный ручной/еженедельный CI workflow с 30-минутным timeout и сохраняемыми diagnostics; обычный push по-прежнему использует быстрый Compose E2E.
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies; catalog, activity/spots и records/community/status/import-history уже обслуживают отдельные `APIRouter`.
Публичный API зафиксирован генерируемым [OpenAPI-контрактом](docs/api-contract.md): CI сравнивает `apps/api/openapi.json` с фактической схемой FastAPI, поэтому рефакторинг routers не может незаметно изменить URL, параметры или response models. Декомпозиция выполняется инкрементально: общий `Db` вынесен в dependencies; catalog, activity/spots и records/community/status/import-history уже обслуживают отдельные `APIRouter`. Проверка доверенных proxy и persistent rate limit отправки улова изолированы в `submission_security`.
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
+4 -37
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from ipaddress import IPv4Address, IPv6Address, IPv4Network, IPv6Network
import hashlib
import hmac
import json
@@ -16,7 +15,7 @@ import httpx
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import delete, func, select, text
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload
@@ -33,6 +32,8 @@ from .routers.public_data import router as public_data_router
from .public_cache import public_cache
from .schemas import ActivityOut, AdminCatchReportOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot
from .submission_security import check_rate_limit
from .submission_security import is_trusted_proxy as _is_trusted_proxy
configure_logging(settings.log_level)
@@ -395,39 +396,5 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad
return Response(status_code=204)
def _is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
"""Check if address is in trusted proxy CIDRs."""
try:
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
except ValueError:
return False
for cidr in trusted_cidrs:
try:
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
if addr in network:
return True
except ValueError:
continue
return False
def _check_rate_limit(request: Request, db: Session) -> None:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(minutes=10)
# Extract real client IP from forwarded headers
client = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
# Only trust X-Forwarded-For if connection came from a trusted proxy
if forwarded and request.client and _is_trusted_proxy(request.client.host, settings.trusted_proxy_cidrs):
client = forwarded.split(",")[0].strip()
client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
recent = db.scalar(select(func.count()).select_from(SubmissionAttempt).where(SubmissionAttempt.client_hash == client_hash, SubmissionAttempt.created_at >= cutoff)) or 0
if recent >= 5:
db.commit()
raise HTTPException(status_code=429, detail="too many submissions")
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
db.commit()
check_rate_limit(request, db, settings)
+56
View File
@@ -0,0 +1,56 @@
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
from typing import Protocol
from fastapi import HTTPException, Request
from sqlalchemy import delete, func, select, text
from sqlalchemy.orm import Session
from .models import SubmissionAttempt
class RateLimitConfig(Protocol):
rate_limit_secret: str
trusted_proxy_cidrs: list[str]
def is_trusted_proxy(address: str, trusted_cidrs: list[str]) -> bool:
try:
addr = IPv4Address(address) if ":" not in address else IPv6Address(address)
except ValueError:
return False
for cidr in trusted_cidrs:
try:
network = IPv4Network(cidr) if ":" not in cidr else IPv6Network(cidr)
if addr in network:
return True
except ValueError:
continue
return False
def check_rate_limit(request: Request, db: Session, config: RateLimitConfig) -> None:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(minutes=10)
client = request.client.host if request.client else "unknown"
forwarded = request.headers.get("x-forwarded-for")
if forwarded and request.client and is_trusted_proxy(request.client.host, config.trusted_proxy_cidrs):
client = forwarded.split(",")[0].strip()
client_hash = hmac.new(config.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest()
if db.get_bind().dialect.name == "postgresql":
lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF
db.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": lock_key})
db.execute(delete(SubmissionAttempt).where(SubmissionAttempt.created_at < now - timedelta(days=1)))
recent = db.scalar(
select(func.count()).select_from(SubmissionAttempt).where(
SubmissionAttempt.client_hash == client_hash,
SubmissionAttempt.created_at >= cutoff,
)
) or 0
if recent >= 5:
db.commit()
raise HTTPException(status_code=429, detail="too many submissions")
db.add(SubmissionAttempt(client_hash=client_hash, created_at=now))
db.commit()
+1 -1
View File
@@ -51,7 +51,7 @@
Аудит выполнен на старой базе `13e04e6`; рекомендации ниже повторно проверены по текущей ветке. Уже реализованные или неприменимые предложения не возвращаются в backlog.
- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`; общий `Db` находится в dependencies, нормализация времени — в `time_utils`. URL, response models и generated contract сохранены. Далее: submissions и admin отдельными пакетами.
- [ ] **Q11 · Декомпозиция API — в работе.** После фиксации OpenAPI публичные catalog, activity/spots и records/community/status/import-history вынесены в отдельные `APIRouter`; общий `Db` находится в dependencies, нормализация времени — в `time_utils`, доверие к proxy и persistent rate limit — в `submission_security`. URL, response models и generated contract сохранены. Далее: submission endpoints и admin отдельными пакетами.
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
- [ ] **Q14 · Полная CSP.** Расширить текущую CSP (`frame-ancestors`, `base-uri`, `object-src`) до `default-src`, `script-src`, `style-src`, `img-src`, `connect-src` и `form-action`. Сначала инвентаризировать inline scripts/styles Astro, затем внедрить nonce/hash или безопасное вынесение; проверить report/admin/OG без ослабления до произвольных внешних origin.