Files
la-voix-du-peuple/docs/SECURITE_ANTI_ABUS.md
T
billisdead defc07648c Security: close the 9 items from the June review + one found along the way
Findings from the 2026-06-24 security review, now mandatory before this
platform leaves the LAN for a public VPS (see budget/externalization note):

- Timing attack on admin password: hmac.compare_digest instead of !=
- CORS wildcard removed, restricted to ALLOWED_ORIGINS (needed for credentials)
- hCaptcha now fails closed on network error instead of open
- HMAC signatures use the full SHA-256 digest instead of a 64-bit truncation
- Consultation-scoped submissions were missing the consent/cooldown checks
  the global submission route already had
- Admin search capped at 200 chars (ILIKE cost)
- Webhook URLs validated against SSRF (private/loopback/link-local/metadata
  IPs rejected, re-checked at trigger time too, redirects not followed)
- Flood tracker backed by Redis when REDIS_URL is set (shared across Gunicorn
  workers, survives restarts), falls back to in-memory otherwise
- Admin session moved from sessionStorage/Bearer token to an httpOnly+Secure+
  SameSite=Strict cookie; new /api/admin/logout and /api/admin/session routes

Also found while wiring ProxyFix for the cookie's Secure flag: the app never
told Flask about the Nginx reverse proxy, so request.remote_addr saw Nginx's
own IP for every request in production — silently breaking rate limiting,
flood detection, and IP blacklist (everyone would collapse onto one shared
key). Added ProxyFix (1 hop).

CSV exports now stream instead of loading everything into memory. AI prompts
delimit citizen-submitted content explicitly and instruct the model to treat
it as inert data, never instructions, to reduce prompt injection surface.

Docs (DEX.md, SECURITE_ANTI_ABUS.md, README.md, DEPLOIEMENT.md) and
dev-local.sh updated to match.
2026-09-10 09:27:50 +02:00

11 KiB

Sécurité anti-abus — La Voix du Peuple

Document technique décrivant les protections contre les attaques sybil, les floods de bots et les brigading coordonnés. Mis à jour : mai 2026.


Contexte et risque

La plateforme est conçue sans authentification (choix philosophique préservant l'anonymat). Cette absence rend triviale, sans protection, la manipulation des données par :

  • Sybil attacks : multiplication des soumissions depuis la même entité
  • Bot floods : soumissions automatisées en masse
  • Brigading coordonné : campagnes organisées pour inonder la synthèse de contenus orientés

Les protections suivantes traitent ce risque sans remettre en cause l'anonymat des contributeurs légitimes.


Couches de protection (ordre d'application)

1. Honeypot anti-bot

Principe : un champ de formulaire est présent dans le HTML mais rendu invisible aux utilisateurs réels (display: none, position: absolute, aria-hidden="true"). Les bots qui analysent le DOM et remplissent tous les champs déclenchent le honeypot.

Comportement :

  • Client : si le champ _hp a une valeur lors de la soumission, l'appel API n'est pas effectué. Réponse simulée silencieuse côté JS.
  • Serveur : si _hp est présent et non vide dans le corps JSON, le serveur retourne un 201 factice sans enregistrer quoi que ce soit (logger.info("Honeypot déclenché")).

Fichiers : artifacts/voix-du-peuple/src/pages/home.tsx · artifacts/flask-api/app.py


2. hCaptcha (stub — activer en production)

Principe : widget CAPTCHA humain présenté avant la soumission. hCaptcha est choisi pour :

  • Gratuit (tier communautaire)
  • RGPD-compliant (pas de cookies tiers, données UE)
  • Pas de dépendance à Google

État actuel : stub intégré, désactivé par défaut.

Pour activer :

  1. Créer un compte sur hcaptcha.com
  2. Créer un site, récupérer la clé de site et la clé secrète
  3. Configurer les variables d'environnement :
# Frontend (.env dans artifacts/voix-du-peuple/)
VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site

# Backend (.env ou variable système)
HCAPTCHA_SECRET_KEY=votre-cle-secrete
  1. Reconstruire le frontend : pnpm build

Comportement quand activé :

  • Le widget hCaptcha s'affiche dans le formulaire avant le bouton "Contribuer"
  • Le bouton est désactivé tant que le CAPTCHA n'est pas validé
  • Le token est transmis dans l'en-tête X-HCaptcha-Token
  • Le backend vérifie le token via l'API hCaptcha (https://hcaptcha.com/siteverify)
  • Si la clé secrète n'est pas configurée côté serveur, la vérification est sautée (dégradation gracieuse)
  • Si la clé secrète est configurée mais que l'appel réseau à hCaptcha échoue, la soumission est rejetée (fail-closed) — une panne du service tiers ne doit jamais désactiver silencieusement la protection une fois activée

Fichiers : artifacts/voix-du-peuple/src/pages/home.tsx · artifacts/flask-api/app.py (_verify_hcaptcha())


3. Rate limiting par IP + fingerprint

Outil : Flask-Limiter v3 avec stockage Redis (si REDIS_URL défini) ou mémoire (dev).

Seuils par défaut (configurables via variables d'environnement) :

Endpoint Limite par défaut Variable de contrôle
POST /api/ideas 5/min · 3/heure RATE_LIMIT_CONTRIBUTIONS
POST /api/ideas/:id/flag 3/min · 10/heure —
POST /api/admin/login 10/min —
Toutes routes 60/heure · 200/jour —

Clé de rate limiting : priorité au fingerprint FingerprintJS (hashé), sinon IP. Empêche de contourner la limite en changeant d'IP si le fingerprint est reconnu.

Pour activer Redis :

REDIS_URL=redis://localhost:6379/0

Sans Redis, le rate limiting est en mémoire (reset au redémarrage — suffisant pour une instance unique).

Fichiers : artifacts/flask-api/app.py (get_fingerprint_key(), RATE_LIMIT_CONTRIBUTIONS)


4. Fingerprinting non-PII

Outil : @fingerprintjs/fingerprintjs v4 (open source, pas de compte requis).

Principe : FingerprintJS génère un visitorId côté client à partir de caractéristiques du navigateur (User-Agent, timezone, canvas fingerprint, etc.) sans créer de cookie tiers ni stocker de données personnelles.

Flux :

  1. À l'initialisation de l'app React (App.tsx), FingerprintJS est chargé
  2. Le visitorId est stocké en mémoire (non persisté)
  3. Il est envoyé sur chaque requête API dans l'en-tête X-Visitor-Id
  4. Le backend le hash en SHA-256 (32 premiers hex) avant tout stockage
  5. Le hash est enregistré en base (ideas.fingerprint_hash) pour analyse post-hoc si nécessaire

Données stockées : uniquement le hash SHA-256 tronqué — non-réversible, non-PII au sens du RGPD. Aucun cookie, aucun suivi cross-site.

Fichiers : artifacts/voix-du-peuple/src/App.tsx · lib/api-client-react/src/custom-fetch.ts · artifacts/flask-api/app.py · artifacts/flask-api/database.py


Principe : après une soumission acceptée, un cookie httpOnly signé HMAC-SHA256 est posé. Toute tentative de soumission avant l'expiration du cooldown est rejetée avec un 429.

Durée par défaut : 3600 secondes (1 heure), configurable :

CONTRIBUTION_COOLDOWN_SECONDS=3600

Signature : le cookie _cv contient {timestamp}.{signature} où la signature est HMAC-SHA256(SECRET_KEY, timestamp_bytes) (digest complet, 256 bits). Impossible de forger sans connaître SECRET_KEY.

Prérequis :

SECRET_KEY=une-longue-chaine-aleatoire-minimum-32-chars

Si SECRET_KEY n'est pas défini, le cooldown est désactivé (dégradation gracieuse).

Cross-origin (dev comme prod) : depuis le passage de CORS en supports_credentials=True (nécessaire pour le cookie de session admin, voir section Panel admin), le cookie _cv est aussi envoyé correctement en développement cross-origin (Vite sur un port différent de Flask), à condition que l'origine Vite figure dans ALLOWED_ORIGINS.

Fichiers : artifacts/flask-api/app.py (_sign_cooldown(), _verify_cooldown())


6. Détection de flood

Principe : compteur par IP (et par fingerprint si disponible) sur une fenêtre glissante de 5 minutes. Si le seuil est dépassé, une alerte WARNING est émise dans les logs. Backend Redis (sorted set, EXPIRE sur la fenêtre) si REDIS_URL est configuré — partagé entre tous les workers Gunicorn et persiste au redémarrage ; repli automatique sur un dict en mémoire du process sinon (ou si Redis devient injoignable).

Seuil par défaut : 10 soumissions en 5 minutes, configurable :

FLOOD_THRESHOLD=10

Ce qui se passe : l'alerte est loggée mais la soumission n'est pas bloquée (le rate limiter Flask-Limiter s'en charge). L'objectif est d'alerter l'opérateur pour investigation.

Format de l'alerte :

WARNING ALERTE FLOOD — IP: 1.2.3.4 | fingerprint: abc123... | seuil: 10/5min

Pour aller plus loin : brancher sur un webhook (email Mailgun/Brevo, Slack/Mattermost) via un hook sur les logs WARNING avec le pattern ALERTE FLOOD.

Limite (sans REDIS_URL) : l'état est en mémoire, propre à chaque worker Gunicorn, et se réinitialise au redémarrage — recommandé de configurer REDIS_URL en production multi-workers.

Fichiers : artifacts/flask-api/app.py (_check_flood(), _flood_tracker)


8. Blacklist IP automatique

Principe : lorsqu'une IP soumet deux contributions illégales (rejetées par le filtre IA), elle est automatiquement bloquée pour une durée configurable. La première soumission illégale est tolérée (bénéfice du doute) ; la deuxième déclenche le blocage, quelle que soit la durée écoulée entre les deux.

Caractéristiques RGPD :

  • L'adresse IP brute n'est jamais stockée. Seul un hash SHA-256 tronqué (32 caractères, non réversible) est conservé.
  • Base légale : intérêt légitime (RGPD art. 6(1)(f)) — protection de la plateforme contre les contenus illicites.
  • Durée de conservation : 30 jours par défaut (IP_BLACKLIST_DAYS), configurable.
  • Mention dans la politique de confidentialité : oui (section « Quelles données sont collectées ? »).

Implémentation :

  • Table ip_abuse (PostgreSQL) : ip_hash, rejection_count, horodatages, last_idea_id, blacklisted_at, expires_at.
  • Au 2ème rejet : blacklisted_at et expires_at sont renseignés ; toute tentative ultérieure retourne 403 ip_blacklisted.
  • Le compteur est cumulatif : une contribution valide entre deux rejets ne remet pas le compteur à zéro.
  • Persistant cross-workers et cross-restarts (PostgreSQL, pas mémoire).

Gestion admin :

  • Le panel admin affiche les IPs blacklistées actives (hash + date + contribution déclencheuse).
  • Un bouton « Lever » permet à l'admin de supprimer une entrée manuellement (faux positif, recours).
  • Route : GET /api/admin/ip-blacklist · DELETE /api/admin/ip-blacklist/<ip_hash>

Limite : contournable par changement d'IP (VPN, Tor). Combiné avec le fingerprint FingerprintJS, le contournement est plus coûteux mais reste possible.

Fichiers : artifacts/flask-api/database.py (check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist) · artifacts/flask-api/app.py (_get_ip_hash, routes admin)


Variables d'environnement récapitulatif

# Rate limiting
REDIS_URL=redis://localhost:6379/0           # Optionnel — sinon mémoire
RATE_LIMIT_CONTRIBUTIONS=5 per minute;3 per hour  # Format flask-limiter

# Cooldown session
SECRET_KEY=une-longue-chaine-aleatoire-minimum-32-chars
CONTRIBUTION_COOLDOWN_SECONDS=3600

# Flood detection
FLOOD_THRESHOLD=10

# hCaptcha (désactivé si absent)
HCAPTCHA_SECRET_KEY=votre-cle-secrete        # Backend
VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site     # Frontend (nécessite rebuild)

# Blacklist IP
IP_BLACKLIST_DAYS=30                         # Durée du blocage après 2 rejets illégaux

Ce que ces protections ne couvrent pas

  • Bots sophistiqués JavaScript-capable : FingerprintJS peut être contourné par un navigateur headless bien configuré. La combinaison IP + fingerprint + hCaptcha rend l'attaque coûteuse mais pas impossible.
  • VPN / Tor : le rate limiting IP peut être contourné. Le fingerprint compense partiellement.
  • Submissions manuelles coordonnées (brigading humain) : seul le contenu + la modération IA protège contre ce vecteur.

Important — déploiement derrière Nginx : l'app est protégée par ProxyFix (1 saut de proxy, voir deploy/nginx.conf) pour lire la vraie IP cliente (X-Forwarded-For) et le vrai schéma (X-Forwarded-Proto). Sans ça, toutes les requêtes verraient l'IP de Nginx — le rate limiting, la détection de flood et la blacklist IP retomberaient sur une seule clé partagée par tous les visiteurs. Si le nombre de sauts de proxy change (ex. ajout d'un CDN devant Nginx), ajuster x_for/x_proto dans app.py en conséquence.


Évolutions futures recommandées

  1. hCaptcha : activer dès que la plateforme est ouverte au public (clé gratuite, 5 minutes de setup).
  2. Alerte flood automatique : brancher un webhook Brevo/Mailgun sur les logs ALERTE FLOOD.
  3. CAPTCHA invisible : envisager hCaptcha en mode "invisible" (score-based) pour ne pas imposer de défi aux utilisateurs légitimes.