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.
This commit is contained in:
1 parent
2c0bb379c9
commit
defc07648c
10 files changed
+382
-116
No files matched your search
+10
-4
@@ -361,11 +361,12 @@ https://votredomaine.fr/admin
|
||||
|
||||
### Authentification
|
||||
|
||||
À l'ouverture, un formulaire de connexion demande le mot de passe. Celui-ci est la valeur de la variable `ADMIN_SECRET` configurée dans les secrets de l'environnement. La session est stockée dans `sessionStorage` du navigateur — elle est perdue à la fermeture de l'onglet.
|
||||
À l'ouverture, un formulaire de connexion demande le mot de passe. Celui-ci est la valeur de la variable `ADMIN_SECRET` configurée dans les secrets de l'environnement. La session vit dans un cookie `admin_session` **httpOnly** signé (jamais lisible ni stocké par le JavaScript du navigateur) — expire au bout de 8h, ou plus tôt via le bouton Déconnexion.
|
||||
|
||||
| Variable | Où la définir |
|
||||
|----------|---------------|
|
||||
| `ADMIN_SECRET` | `.env` ou variable d'environnement système |
|
||||
| `ALLOWED_ORIGINS` | `.env` — domaine public du site (requis pour que le cookie de session soit accepté en cross-origin) |
|
||||
|
||||
### Fonctionnalités
|
||||
|
||||
@@ -388,16 +389,19 @@ Sur la page d'accueil, chaque contribution affiche un bouton **Signaler** au sur
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Toutes les routes `/api/admin/*` vérifient le header `Authorization: Bearer <ADMIN_SECRET>`
|
||||
- Toutes les routes `/api/admin/*` vérifient le cookie `admin_session` (httpOnly, `Secure` en HTTPS, `SameSite=Strict`), jamais un header exposé au JS
|
||||
- Comparaison du mot de passe en temps constant (`hmac.compare_digest`) — pas de faille de timing
|
||||
- La route de login est limitée à 10 tentatives par minute (protection brute-force)
|
||||
- Aucune session persistante côté serveur — pas de cookie, pas de base de sessions
|
||||
- Aucune session persistante côté serveur — le cookie signé HMAC-SHA256 porte sa propre expiration (8h), pas de base de sessions
|
||||
- L'URL `/admin` n'est pas mentionnée dans le code source public ni dans le `robots.txt`
|
||||
|
||||
### Routes API admin
|
||||
|
||||
| Méthode | Route | Action |
|
||||
|---------|-------|--------|
|
||||
| `POST` | `/api/admin/login` | Vérification du mot de passe |
|
||||
| `POST` | `/api/admin/login` | Vérification du mot de passe, pose le cookie `admin_session` |
|
||||
| `POST` | `/api/admin/logout` | Efface le cookie de session |
|
||||
| `GET` | `/api/admin/session` | Indique si le cookie de session est encore valide |
|
||||
| `GET` | `/api/admin/stats` | Statistiques détaillées |
|
||||
| `GET` | `/api/admin/ideas` | Liste avec filtres (`status`, `page`, `q`) |
|
||||
| `DELETE` | `/api/admin/ideas/<id>` | Suppression unitaire |
|
||||
@@ -452,6 +456,8 @@ Si `webhook_url` est configuré, un POST JSON est envoyé à la clôture :
|
||||
}
|
||||
```
|
||||
|
||||
**Validation SSRF** : l'URL doit être `http(s)://` et résoudre vers une IP publique — toute IP privée/loopback/lien-local/réservée (réseau interne, `169.254.169.254` metadata cloud...) est rejetée, à la création **et** à chaque déclenchement (protège aussi une consultation créée avant ce contrôle, et contre le DNS rebinding). Les redirections HTTP ne sont pas suivies, pour la même raison.
|
||||
|
||||
### Export PDF d'une consultation
|
||||
|
||||
```
|
||||
|
||||
@@ -60,6 +60,7 @@ HCAPTCHA_SECRET_KEY=votre-cle-secrete
|
||||
- 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()`)
|
||||
|
||||
@@ -119,7 +120,7 @@ Sans Redis, le rate limiting est en mémoire (reset au redémarrage — suffisan
|
||||
CONTRIBUTION_COOLDOWN_SECONDS=3600
|
||||
```
|
||||
|
||||
**Signature** : le cookie `_cv` contient `{timestamp}.{signature}` où la signature est `HMAC-SHA256(SECRET_KEY, timestamp_bytes)[:16]`. Impossible de forger sans connaître `SECRET_KEY`.
|
||||
**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** :
|
||||
```env
|
||||
@@ -128,7 +129,7 @@ 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).
|
||||
|
||||
**Limite** : fonctionne pleinement en production (même domaine, Nginx reverse proxy). En développement cross-origin (Vite sur port différent de Flask), le cookie n'est pas envoyé automatiquement par le navigateur (CORS `supports_credentials=False`).
|
||||
**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()`)
|
||||
|
||||
@@ -136,7 +137,7 @@ Si `SECRET_KEY` n'est pas défini, le cooldown est désactivé (dégradation gra
|
||||
|
||||
### 6. Détection de flood
|
||||
|
||||
**Principe** : compteur en mémoire 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.
|
||||
**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 :
|
||||
```env
|
||||
@@ -152,7 +153,7 @@ 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** : l'état est en mémoire et se réinitialise au redémarrage. Pour une persistance cross-restart, utiliser Redis directement avec `EXPIRE`.
|
||||
**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`)
|
||||
|
||||
@@ -214,13 +215,13 @@ IP_BLACKLIST_DAYS=30 # Durée du blocage après 2 rejets
|
||||
- **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.
|
||||
- **Cross-origin en dev** : le cookie cooldown ne fonctionne pas en développement (ports différents, CORS sans credentials).
|
||||
|
||||
**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. **Redis** : déployer Redis et configurer `REDIS_URL` en production pour un rate limiting persistant et cross-process.
|
||||
2. **hCaptcha** : activer dès que la plateforme est ouverte au public (clé gratuite, 5 minutes de setup).
|
||||
3. **Alerte flood automatique** : brancher un webhook Brevo/Mailgun sur les logs `ALERTE FLOOD`.
|
||||
4. **CAPTCHA invisible** : envisager hCaptcha en mode "invisible" (score-based) pour ne pas imposer de défi aux utilisateurs légitimes.
|
||||
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.
|
||||
Reference in new issue
Block a user