- Lien d'inscription protégé par un jeton (FormTemplate.access_token, /inscription/<token>), avec régénération depuis l'écran de configuration. - Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en mécénat, donateur/montant (chiffres et toutes lettres en français, app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel. - Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée. - Infobulles (HelpTip et détail de cellule de la grille des besoins) repositionnées en position: fixed via JS, bornées à l'écran. - Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une URL (fuyait le jeton d'inscription), sujet retiré du log de remise des emails (pouvait porter un nom de bénévole), photo du formulaire public revalidée par décodage réel avant stockage, export Excel protégé contre l'injection de formule, sondage du statut de paiement limité en fréquence, pyjwt mis à jour. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
81 lines
3.9 KiB
Python
81 lines
3.9 KiB
Python
"""Limiteur de tentatives, en mémoire — protège `/auth/login` (force brute
|
|
sur le mot de passe COORDINATION) et `/auth/magic-link` (spam d'emails vers
|
|
une victime, ou vers le SMTP) faute d'un reverse-proxy qui le ferait à notre
|
|
place.
|
|
|
|
Clé par **email seul**, pas par IP : le service tourne derrière Caddy sur le
|
|
VPS (voir `deploy/`), qui ne transmet pas l'IP réelle du client par défaut —
|
|
il faudrait faire confiance à `X-Forwarded-For` en configurant un proxy de
|
|
confiance, ce que ce déploiement ne fait pas encore. Limiter par email suffit
|
|
au risque réel ici : il n'y a qu'un très petit nombre de comptes protégés par
|
|
mot de passe (la coordination), donc l'attaque à contrer est « deviner le
|
|
mot de passe d'un compte connu », pas une attaque distribuée sur des emails
|
|
au hasard.
|
|
|
|
En mémoire, un seul processus uvicorn (voir `Dockerfile`, pas de
|
|
`--workers`) : un compteur partagé entre threads (les endpoints sont
|
|
synchrones, exécutés dans un pool de threads par Starlette) suffit, pas
|
|
besoin de Redis pour ce volume."""
|
|
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import HTTPException, status
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(self, max_attempts: int, window_seconds: float) -> None:
|
|
self.max_attempts = max_attempts
|
|
self.window_seconds = window_seconds
|
|
self._hits: dict[str, list[float]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def hit(self, key: str) -> None:
|
|
"""Enregistre une tentative pour `key` ; lève 429 si la limite du
|
|
créneau glissant est dépassée."""
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
recent = [
|
|
t for t in self._hits.get(key, []) if now - t < self.window_seconds
|
|
]
|
|
if len(recent) >= self.max_attempts:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail={"code": "error.too_many_requests"},
|
|
)
|
|
recent.append(now)
|
|
self._hits[key] = recent
|
|
|
|
def reset(self) -> None:
|
|
"""Tests uniquement."""
|
|
with self._lock:
|
|
self._hits.clear()
|
|
|
|
|
|
# 5 tentatives/minute : large pour un vrai utilisateur qui se trompe, serré
|
|
# pour de la force brute (bcrypt ralentit déjà chaque essai, ceci coupe
|
|
# l'automatisation par-dessus).
|
|
login_limiter = RateLimiter(max_attempts=5, window_seconds=60)
|
|
# 3 demandes/5 min : un bénévole qui n'a pas reçu son email réessaie deux ou
|
|
# trois fois ; au-delà c'est soit un abus, soit un email qui n'arrivera pas
|
|
# plus vite en insistant.
|
|
magic_link_limiter = RateLimiter(max_attempts=3, window_seconds=300)
|
|
# Formulaire public d'inscription (`app/api/forms.py`) : pas d'identité
|
|
# fiable à clé (n'importe qui peut soumettre des emails différents à chaque
|
|
# fois, et l'IP réelle du client n'est pas encore transmise par Caddy — voir
|
|
# plus haut) ; hCaptcha est la défense principale contre les robots, ceci
|
|
# n'est qu'un filet de sécurité global côté édition contre un emballement
|
|
# malgré tout (30 inscriptions/5 min est très large pour un usage réel).
|
|
form_submission_limiter = RateLimiter(max_attempts=30, window_seconds=300)
|
|
# Interrogation du statut d'une inscription en attente de paiement
|
|
# (`GET .../form/public/pending/{token}`) : relit toujours l'état réel
|
|
# auprès de SumUp (voir `app/api/forms.py:get_pending_status`), donc chaque
|
|
# appel coûte une requête à l'API SumUp de l'édition. Clé par jeton (pas par
|
|
# édition, contrairement à `form_submission_limiter`) : plusieurs
|
|
# inscriptions en cours en même temps ne doivent pas s'épuiser mutuellement
|
|
# leur quota de sondage. 60/5 min couvre largement le sondage légitime (la
|
|
# page de confirmation interroge toutes les 3 s, jusqu'à 40 fois — voir
|
|
# `PublicSignupPage.tsx`), tout en bornant un sondage en boucle serrée qui
|
|
# n'aurait d'autre effet que de solliciter inutilement l'API SumUp.
|
|
pending_status_limiter = RateLimiter(max_attempts=60, window_seconds=300)
|