fix: A26 + A33 + A34 + A35 + A36 + A43 — bande S/XS du P2 (v7.3.9)
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut - A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` + non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests` (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié - A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning - A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ; le drift Python 3.12/3.13 reste noté (rebuild d'image à valider) - A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi, loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources - A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO naïf identique, warnings de dépréciation divisés : 2374 → 926) suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -97,8 +98,16 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
# Paths that should be rate-limited
|
||||
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
|
||||
"/api/", "/board/api/", "/auth/",
|
||||
# A33 : préfixes qui manquaient — SCIM (brute force de jetons/IdP),
|
||||
# API workspace + collections (les endpoints mutants du legacy).
|
||||
"/scim/v2/", "/workspace/", "/db/",
|
||||
)
|
||||
|
||||
# Pages publiques : seul le non-GET est plafonné (brute force de
|
||||
# /s/<slug>/auth et spam de /f/<token>) — la lecture reste libre pour les
|
||||
# visiteurs d'un site publié qui partagent une IP.
|
||||
RATE_LIMITED_NON_GET_PREFIXES: tuple[str, ...] = ("/s/", "/f/")
|
||||
|
||||
# Paths exempt from rate limiting even under an API prefix
|
||||
EXEMPT_PATHS: frozenset[str] = frozenset({
|
||||
"/api/health",
|
||||
@@ -106,11 +115,15 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"/api/frontend-errors",
|
||||
})
|
||||
|
||||
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
|
||||
def __init__(self, app, max_requests: int | None = None, window_seconds: int = 60):
|
||||
super().__init__(app)
|
||||
# A33 : None = lire `settings.rate_limit_requests` à chaque requête (la
|
||||
# valeur de config n'était jamais lue — 100 codé en dur contre 60 annoncé).
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
|
||||
self._last_prune = 0.0
|
||||
self._max_keys = 5000
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
@@ -120,27 +133,64 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
if not settings.rate_limit_enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Only rate-limit API routes
|
||||
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
|
||||
# Only rate-limit API routes (+ non-GET sur les pages publiques)
|
||||
method = request.method.upper()
|
||||
limited = any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES) or (
|
||||
method not in ("GET", "HEAD", "OPTIONS")
|
||||
and any(path.startswith(p) for p in self.RATE_LIMITED_NON_GET_PREFIXES)
|
||||
)
|
||||
if not limited:
|
||||
return await call_next(request)
|
||||
|
||||
# Exempt health check and error capture
|
||||
if path in self.EXEMPT_PATHS:
|
||||
return await call_next(request)
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
limit = self.max_requests or settings.rate_limit_requests
|
||||
ip = self._client_key(request)
|
||||
now = time.time()
|
||||
|
||||
# A33 : le store n'était jamais épuré → croissance mémoire par IP.
|
||||
if len(self._store) > self._max_keys and now - self._last_prune > self.window_seconds:
|
||||
self._prune(now)
|
||||
|
||||
window_start, count = self._store[ip]
|
||||
if now - window_start > self.window_seconds:
|
||||
self._store[ip] = (now, 1)
|
||||
return await call_next(request)
|
||||
|
||||
if count >= self.max_requests:
|
||||
if count >= limit:
|
||||
return JSONResponse(
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
|
||||
{"error": "Rate limit exceeded", "detail": f"Max {limit} req/min per IP"},
|
||||
status_code=429,
|
||||
)
|
||||
|
||||
self._store[ip] = (window_start, count + 1)
|
||||
return await call_next(request)
|
||||
|
||||
def _client_key(self, request: Request) -> str:
|
||||
"""A33 : X-Forwarded-For uniquement derrière un proxy local.
|
||||
|
||||
Le test porte sur `is_private` (Python ≥ 3.13 : toutes les plages non
|
||||
globales, pas seulement RFC1918) — un pair non-global n'est pas un
|
||||
internaute, donc le XFF du proxy fait foi.
|
||||
ponytail: si un jour plusieurs proxies se chaînent (ou si l'app est
|
||||
exposée directement), prendre la dernière adresse non privée de la
|
||||
chaîne plutôt que la première.
|
||||
"""
|
||||
host = request.client.host if request.client else "unknown"
|
||||
fwd = request.headers.get("x-forwarded-for", "")
|
||||
if fwd:
|
||||
try:
|
||||
direct = ipaddress.ip_address(host)
|
||||
if direct.is_private or direct.is_loopback:
|
||||
return fwd.split(",")[0].strip() or host
|
||||
except ValueError:
|
||||
pass # hôte non-IP (testserver…) → on garde la clé d'origine
|
||||
return host
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
expired = [k for k, (start, _) in self._store.items() if now - start > self.window_seconds]
|
||||
for k in expired:
|
||||
del self._store[k]
|
||||
self._last_prune = now
|
||||
|
||||
Reference in New Issue
Block a user