Files
la-voix-du-peuple/docs/DEX.md
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

17 KiB

Document d'Exploitation — La Voix du Peuple

Version : 1.7
Date : Mai 2026


Historique des versions

Version Date Modifications
1.0 Avril 2026 Version initiale
1.1 Avril 2026 Ajout section flyer QR, export PDF, partage horodaté
1.2 Avril 2026 Palette pétrol neutre, textes de posture sur l'expression vs. vérité
1.3 Avril 2026 Dark mode pétrol, panneau d'accessibilité (dyslexie, contraste, zoom)
1.4 Avril 2026 Synchronisation Gitea sécurisée — GITEA_TOKEN + scripts/push-gitea.sh
1.5 Avril 2026 Droit pénal français intégré dans le filtre IA (16 sections)
1.6 Avril 2026 Panel admin sécurisé (/admin), signalement public, export CSV
1.7 Mai 2026 P5 — Mode consultation ciblée (table consultations, webhook, auto-fermeture, panel admin)

1. Démarrage et arrêt des services

En auto-hébergement (RockyLinux / Debian)

# Démarrer le backend
systemctl start voix-du-peuple-api

# Arrêter le backend
systemctl stop voix-du-peuple-api

# Redémarrer après mise à jour
systemctl restart voix-du-peuple-api

# Voir le statut
systemctl status voix-du-peuple-api

# Activer au démarrage
systemctl enable voix-du-peuple-api
# Redémarrer Nginx après modification de la config
nginx -t && systemctl reload nginx

2. Variables d'environnement

Fichier de référence : .env.example à la racine du projet.

En production, créer /etc/voix-du-peuple/.env :

# Base de données
DATABASE_URL=postgresql://user:password@localhost:5432/voix_du_peuple

# IA — choisir l'un des deux
MISTRAL_API_KEY=sk-...
# OPENAI_API_KEY=sk-...

# Modèles (optionnel — valeurs par défaut ci-dessous)
FILTER_MODEL=mistral-small-latest
SYNTHESIS_MODEL=mistral-large-latest

# Sécurité Flask
SESSION_SECRET=une-longue-chaine-aleatoire-securisee

Important

: Après toute modification du fichier .env, redémarrer le service API.


3. Mise à jour du code

# Depuis votre serveur, après un git pull
cd /opt/voix-du-peuple

git pull origin main

# Mettre à jour les dépendances Python si requirements.txt a changé
pip install -r artifacts/flask-api/requirements.txt

# Mettre à jour les dépendances Node si package.json a changé
pnpm install

# Reconstruire le frontend si le code frontend a changé
pnpm --filter @workspace/voix-du-peuple run build --config vite.config.selfhost.ts

# Redémarrer l'API
systemctl restart voix-du-peuple-api

4. Consultation des logs

En auto-hébergement

# Logs du service systemd (temps réel)
journalctl -u voix-du-peuple-api -f

# 100 dernières lignes
journalctl -u voix-du-peuple-api -n 100

# Logs d'une journée spécifique
journalctl -u voix-du-peuple-api --since "2026-04-01" --until "2026-04-02"

# Logs Nginx
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log

5. Base de données

Connexion directe

psql "$DATABASE_URL"
# ou
psql -U voix_user -d voix_du_peuple -h localhost

Requêtes utiles

-- Nombre de contributions par statut
SELECT accepted, COUNT(*) FROM ideas GROUP BY accepted;

-- Dernières contributions
SELECT id, author, left(content, 60), accepted, created_at
FROM ideas ORDER BY created_at DESC LIMIT 10;

-- Contributions refusées avec motif
SELECT id, left(content, 60), rejection_reason, created_at
FROM ideas WHERE accepted = false ORDER BY created_at DESC;

-- État de la synthèse
SELECT idea_count, left(text, 200), updated_at FROM synthesis;

Sauvegarde

# Dump complet
pg_dump "$DATABASE_URL" > backup_$(date +%Y%m%d_%H%M).sql

# Restauration
psql "$DATABASE_URL" < backup_20260401_0800.sql

6. Vérification du bon fonctionnement

# Health check API
curl http://localhost:8080/health

# Test de soumission
curl -X POST http://localhost:8080/api/ideas \
  -H "Content-Type: application/json" \
  -d '{"content": "Test de fonctionnement de la plateforme.", "author": "Admin"}'

# Lecture de la synthèse
curl http://localhost:8080/api/synthesis | python3 -m json.tool

# Statistiques
curl http://localhost:8080/api/ideas/stats

Réponse attendue du health check :

{"status": "ok"}

7. Changement de modèle IA

Pour basculer vers un modèle différent sans toucher au code :

# Exemple : passer à mistral-medium
# Éditer le .env
FILTER_MODEL=mistral-medium-latest
SYNTHESIS_MODEL=mistral-medium-latest

# Redémarrer
systemctl restart voix-du-peuple-api

Modèles Mistral disponibles (avril 2026) :

  • mistral-small-latest — Rapide, économique (filtre)
  • mistral-medium-latest — Équilibré
  • mistral-large-latest — Meilleure qualité (synthèse)

8. Gestion du flyer QR code

La page /flyer génère un flyer A4 imprimable avec un QR code.

Changer l'URL de destination

Méthode 1 — Interface (sans code) : ouvrir /flyer, modifier le champ "Destination du QR code", cliquer "Appliquer", puis imprimer.

Méthode 2 — Paramètre URL : ajouter ?url= à l'adresse de la page, ex. :

https://lavoixdupeuple.fr/flyer?url=https://autresite.fr

Méthode 3 — Modification permanente dans le code : éditer la ligne 10 de artifacts/voix-du-peuple/src/pages/flyer.tsx :

const DEFAULT_QR_URL = "https://lavoixdupeuple.fr";

Reconstruire ensuite le frontend (pnpm build).

Imprimer / exporter en PDF

Sur la page /flyer, cliquer Imprimer / Exporter PDF. La barre de configuration disparaît automatiquement à l'impression, seul le flyer A4 est produit. Pour obtenir un PDF, choisir "Enregistrer en PDF" dans la boîte de dialogue du navigateur.


9. Export de la synthèse

Deux boutons sont disponibles dans l'en-tête de la colonne de synthèse (page principale) :

Partager / Copier

  • Sur mobile (navigateurs supportant Web Share) : ouvre le menu de partage natif du système
  • Sur bureau : copie le texte dans le presse-papier avec horodatage et compteur de contributions ; un message de confirmation apparaît

PDF

Génère et ouvre une page HTML mise en page (tricolore, titre, date, texte de synthèse, pied de page) puis déclenche l'impression. Choisir "Enregistrer en PDF" dans la boîte de dialogue pour obtenir un fichier.

Ces deux fonctions s'exécutent entièrement côté client — aucune donnée n'est envoyée au serveur.


10. Purge des données

-- Supprimer toutes les contributions (irréversible)
TRUNCATE ideas;
TRUNCATE synthesis;

-- Supprimer uniquement les contributions refusées
DELETE FROM ideas WHERE accepted = false;

-- Forcer une re-synthèse (supprimer le cache)
DELETE FROM synthesis;

Après une purge, la synthèse se régénère automatiquement à la prochaine contribution acceptée.


11. Dépannage courant

Symptôme Cause probable Solution
Service temporairement indisponible sur soumission Clé Mistral invalide ou quota dépassé Vérifier MISTRAL_API_KEY, consulter console.mistral.ai
Synthèse non mise à jour Erreur silencieuse en arrière-plan Consulter les logs, vérifier la connexion à l'API IA
Erreur 500 sur toutes les routes DATABASE_URL incorrect ou PostgreSQL arrêté systemctl status postgresql, vérifier le .env
Frontend vide (page blanche) Build manquant ou BASE_URL incorrect Relancer le build Vite, vérifier la config Nginx
Rate limit atteint (429) Trop de soumissions depuis la même IP Normal — attendre 1 minute
Modèle IA introuvable (404) Nom de modèle incorrect dans FILTER_MODEL Corriger le nom, redémarrer le service
QR code ne redirige pas URL incorrecte dans le champ ou la constante Vérifier DEFAULT_QR_URL dans flyer.tsx, ou passer ?url= en paramètre
Bouton "Copier" sans retour visuel Navigateur sans accès au presse-papier (HTTP non sécurisé) Servir le site en HTTPS
Fenêtre PDF bloquée Bloqueur de popups actif Autoriser les popups pour ce site dans le navigateur

12. Accessibilité — fonctionnement et personnalisation

Le panneau d'accessibilité est accessible via l'icône dans la barre de navigation (à gauche du drapeau). Quatre options sont disponibles :

Option Effet Classe CSS sur <html> Clé localStorage
Mode sombre Fond sombre, pétrol clair .dark a11y-dark
Police dyslexie Arial/Verdana, espacement élargi, interligne 2 .dyslexia a11y-dyslexia
Contraste élevé Fond blanc/noir pur, contrastes WCAG AA+ .high-contrast a11y-contrast
Texte agrandi +20 % sur tous les textes .large-text a11y-large

Les préférences sont stockées dans le navigateur (localStorage) et relues automatiquement à chaque visite.

Pour désactiver une option par code (au déploiement, si souhaité), supprimer le ToggleRow correspondant dans src/components/accessibility-panel.tsx.

Fichiers impliqués :

  • src/hooks/use-accessibility.tsx — contexte React, état, persistance
  • src/components/accessibility-panel.tsx — interface utilisateur
  • src/index.css — section /* ─── Accessibilité */ — toutes les classes CSS
  • src/App.tsx — <AccessibilityProvider>, skip-link, id="main-content"

13. Modifier les textes de posture

Les phrases de positionnement éditorial ("expression citoyenne, pas vérité établie", "auteur attaché à l'expertise") sont définies directement dans le code des composants React. Pour les modifier :

Emplacement Fichier
Bandeau d'intro artifacts/voix-du-peuple/src/pages/home.tsx — bloc Bandeau d'introduction
Pied de synthèse artifacts/voix-du-peuple/src/pages/home.tsx — bloc Pied de page fixe
Section "Expression, pas vérité" artifacts/voix-du-peuple/src/pages/about.tsx — section MessageSquare
Encart limites artifacts/voix-du-peuple/src/pages/transparence.tsx — bloc Ce que cette plateforme n'est pas

Après modification, reconstruire le frontend si en production (pnpm build), ou laisser le hot-reload agir en développement.


14. Modifier la palette de couleurs

La couleur principale est définie dans artifacts/voix-du-peuple/src/index.css, ligne --primary. La valeur actuelle est 185 42% 28% (pétrol foncé, politiquement neutre).

/* Pour changer la couleur principale */
--primary: 185 42% 28%;   /* pétrol actuel */
/* Exemples alternatifs neutres :
   210 20% 30%  →  ardoise bleue
   155 35% 28%  →  vert forêt
   270 30% 35%  →  violet institutionnel
*/

Toutes les occurrences de --primary dans le fichier CSS s'appliquent automatiquement à l'ensemble de l'interface. Pas besoin de modifier les composants.


15. Push vers Gitea

git push

Note

: le script scripts/push-gitea.sh permet un push avec token HTTP si besoin (GITEA_TOKEN). Pour Git 2.50+, préférer git remote set-url avec un token en variable d'environnement.

Pour régénérer un token Gitea : Paramètres du compte → Applications → Générer un token
Permissions requises : repository (lecture + écriture).


16. Panel d'administration

Accès

Le panel admin est accessible à l'URL /admin — il n'est pas lié dans la navigation publique. Seul l'administrateur connaît son existence.

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 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

Fonctionnalité Description
Tableau de bord Compteurs : total, acceptées, rejetées, signalées
Liste filtrée Onglets : Toutes · Acceptées · Rejetées · Signalées
Recherche Filtrage textuel en temps réel sur le contenu
Suppression Suppression unitaire avec confirmation, puis régénération automatique de la synthèse
Suppression en masse Sélection multiple → suppression groupée → synthèse régénérée
Override IA Forcer l'acceptation ou le rejet d'une contribution, avec motif
Note admin Annotation interne sur une contribution (invisible du public)
Retrait de signalement Marquer un signalement comme traité
Régénération manuelle Forcer la régénération complète de la synthèse
Export CSV Télécharger toutes les contributions (UTF-8 BOM, compatible Excel)

Signalement public

Sur la page d'accueil, chaque contribution affiche un bouton Signaler au survol (icône drapeau). Ce bouton est limité à 3 signalements par minute et 10 par heure par IP. Les contributions signalées apparaissent en premier dans l'onglet Signalées du panel admin.

Sécurité

  • 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 — 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, 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
POST /api/admin/ideas/bulk-delete Suppression en masse ({ids: [...]})
POST /api/admin/ideas/<id>/override Override IA ({accepted, reason, note})
POST /api/admin/ideas/<id>/unflag Retirer le signalement
POST /api/admin/synthesis/regenerate Forcer la régénération
GET /api/admin/export/csv Export CSV (toutes les contributions)
GET /api/admin/consultations Liste toutes les consultations avec stats
POST /api/admin/consultations Crée une consultation ({slug, title, subject, ...})
POST /api/admin/consultations/<slug>/close Ferme manuellement + déclenche webhook
DELETE /api/admin/consultations/<slug> Supprime une consultation et ses données

17. Mode consultation ciblée

Créer une consultation (panel admin)

  1. Ouvrir /admin → onglet Consultations → Nouvelle consultation
  2. Remplir : slug (URL-safe, ex. budget-mairie-2026), titre, sujet
  3. Optionnel : message d'introduction, organisateur, logo HTTPS, dates, webhook
  4. Les dates sont interprétées comme UTC
  5. La consultation est immédiatement accessible sur /consultation/<slug>

Fermeture automatique

La fermeture automatique est gérée par un thread arrière-plan (toutes les 60 secondes). Si ends_at est défini et dépassé, la consultation est fermée automatiquement et le webhook déclenché.

# Vérifier les consultations actives
psql "$DATABASE_URL" -c "SELECT slug, title, starts_at, ends_at, closed_at FROM consultations;"

Webhook de clôture

Si webhook_url est configuré, un POST JSON est envoyé à la clôture :

{
  "event": "consultation_closed",
  "consultation": {
    "slug": "budget-mairie-2026",
    "title": "Budget participatif 2026",
    "closedAt": "2026-06-30T18:00:00+00:00"
  },
  "synthesis": {
    "text": "...",
    "ideaCount": 42
  }
}

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

GET /api/consultations/<slug>/export/print

Retourne une page HTML auto-imprimante. Accessible depuis le bouton Exporter PDF sur la page de consultation, ou directement en navigateur.


18. Contacts et ressources