Licence EUPL-1.2 + hardening anti-abus

P1 — Licence :
- Ajout du fichier LICENSE (EUPL-1.2 complet)
- README mis à jour : section licence, table docs, vars d'environnement
- En-têtes EUPL ajoutés dans les fichiers sources principaux (Flask, React)

P2 — Hardening anti-abus :
- Rate limiting Redis-ready (REDIS_URL) avec clé fingerprint + IP
- Honeypot anti-bot : champ caché côté client + vérification serveur
- Fingerprinting non-PII via FingerprintJS (hash SHA-256, colonne ideas.fingerprint_hash)
- Cooldown session : cookie httpOnly signé HMAC-SHA256 (SECRET_KEY requis)
- Détection de flood : alerte WARNING si > FLOOD_THRESHOLD soumissions / 5 min
- hCaptcha stub : intégré, activable via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY
- Nouvelles dépendances : redis (backend), @fingerprintjs/fingerprintjs + @hcaptcha/react-hcaptcha (frontend)
- docs/SECURITE_ANTI_ABUS.md : documentation complète des seuils et de la configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 18:05:46 +02:00
parent 57211ad393
commit 45edc1fa77
14 changed files with 881 additions and 38 deletions
+3
View File
@@ -1,4 +1,7 @@
"""
La Voix du Peuple — Agent IA
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Agent IA pour le filtrage éthique et la synthèse démocratique.
Supporte Mistral AI, OpenAI, et les intégrations Replit AI.
"""
+205 -23
View File
@@ -1,29 +1,41 @@
"""
La Voix du Peuple — Backend Flask
==================================
Copyright (C) 2026 billisdead
Licence : European Union Public Licence v. 1.2 (EUPL-1.2)
Plateforme démocratique citoyenne.
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
Sécurité :
- Rate limiting (flask-limiter)
- Rate limiting IP + fingerprint (flask-limiter, Redis si REDIS_URL défini)
- Honeypot anti-bot (champ caché + vérification serveur)
- Fingerprinting non-PII (FingerprintJS hash SHA-256, sans cookie tiers)
- Détection de flood (> FLOOD_THRESHOLD soumissions / 5 min / même IP)
- Cooldown session (cookie httpOnly signé HMAC-SHA256, si SECRET_KEY défini)
- hCaptcha stub (activer via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY)
- Validation et assainissement des entrées (bleach)
- CORS restreint
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
- Protection contre l'injection via requêtes paramétrées (psycopg2)
- En-têtes de sécurité HTTP
- Panel admin protégé par ADMIN_SECRET (Bearer token)
- Aucun secret exposé dans les réponses d'erreur
"""
import csv
import hashlib
import hmac
import io
import os
import json
import logging
import os
import threading
import time
import urllib.parse
import urllib.request
from functools import wraps
import bleach
from flask import Flask, jsonify, request, Response
from flask import Flask, jsonify, make_response, request, Response
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
@@ -43,27 +55,50 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
# ─── Constantes anti-abus ────────────────────────────────────────────────────
# Rate limit pour POST /api/ideas (format flask-limiter)
RATE_LIMIT_CONTRIBUTIONS = os.environ.get(
"RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour"
)
# Cooldown entre deux soumissions d'une même session (secondes)
CONTRIBUTION_COOLDOWN_SECONDS = int(
os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "3600")
)
# Seuil de flood : nombre de soumissions / 5 min / IP avant alerte
FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10"))
FLOOD_WINDOW_SECONDS = 300 # 5 minutes
# État interne flood detection (en mémoire, reset au redémarrage)
_flood_tracker: dict[str, list[float]] = {}
_flood_lock = threading.Lock()
# ─── Application ────────────────────────────────────────────────────────────
app = Flask(__name__)
app.config["JSON_SORT_KEYS"] = False
# CORS : autorise uniquement les origines du même domaine Replit
# CORS : autorise uniquement les origines du même domaine en production
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False)
# Rate limiting — protection anti-spam et anti-DDoS
# Storage Redis si disponible, sinon mémoire (dev / instance unique)
_redis_url = os.environ.get("REDIS_URL", "")
_storage_uri = _redis_url if _redis_url else "memory://"
limiter = Limiter(
get_remote_address,
app=app,
default_limits=["200 per day", "60 per hour"],
storage_uri="memory://",
storage_uri=_storage_uri,
strategy="fixed-window",
)
# ─── En-têtes de sécurité HTTP ───────────────────────────────────────────────
@app.after_request
def set_security_headers(response):
def set_security_headers(response: Response) -> Response:
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
@@ -83,10 +118,12 @@ CONTENT_MIN = 10
CONTENT_MAX = 1000
AUTHOR_MAX = 100
def sanitize_text(text: str) -> str:
"""Supprime tout HTML/JavaScript — protection XSS."""
return bleach.clean(text, tags=[], strip=True).strip()
def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
"""Valide et assainit les données de soumission d'une idée."""
content = data.get("content")
@@ -119,6 +156,7 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
def _get_admin_secret() -> str | None:
return os.environ.get("ADMIN_SECRET")
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
@@ -131,20 +169,95 @@ def require_admin(f):
return f(*args, **kwargs)
return decorated
# ─── Helpers anti-abus ───────────────────────────────────────────────────────
def get_fingerprint_key() -> str:
"""Clé de rate limiting : fingerprint hashé si présent, sinon IP."""
visitor_id = request.headers.get("X-Visitor-Id", "").strip()
if visitor_id:
return "fp:" + hashlib.sha256(visitor_id.encode()).hexdigest()[:32]
return "ip:" + get_remote_address()
def _sign_cooldown(secret: str) -> str:
"""Génère un token de cooldown signé HMAC-SHA256."""
ts = int(time.time())
msg = ts.to_bytes(8, "big")
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
return f"{ts}.{sig}"
def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool:
"""Retourne True si le cooldown est encore actif pour ce cookie."""
try:
ts_str, sig = cookie.rsplit(".", 1)
ts = int(ts_str)
msg = ts.to_bytes(8, "big")
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16]
if not hmac.compare_digest(sig, expected):
return False
return (time.time() - ts) < cooldown_seconds
except Exception:
return False
def _verify_hcaptcha(token: str) -> bool:
"""
Vérifie un token hCaptcha.
Renvoie True si HCAPTCHA_SECRET_KEY n'est pas configuré (stub désactivé).
"""
secret = os.environ.get("HCAPTCHA_SECRET_KEY", "").strip()
if not secret:
return True # Stub désactivé — pas de clé configurée
if not token:
return False
try:
payload = urllib.parse.urlencode({"secret": secret, "response": token}).encode()
req = urllib.request.Request(
"https://hcaptcha.com/siteverify",
data=payload,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=5) as resp:
result = json.loads(resp.read())
return bool(result.get("success", False))
except Exception:
logger.warning("Vérification hCaptcha impossible (erreur réseau) — skip")
return True # En cas d'erreur réseau, on ne bloque pas
def _check_flood(ip: str, fingerprint_hash: str | None) -> bool:
"""
Enregistre la soumission et retourne True si le seuil de flood est dépassé.
Utilise l'empreinte si disponible, sinon l'IP seule.
"""
key = fingerprint_hash if fingerprint_hash else ip
now = time.time()
with _flood_lock:
times = _flood_tracker.get(key, [])
# Nettoyage des timestamps expirés
times = [t for t in times if now - t < FLOOD_WINDOW_SECONDS]
times.append(now)
_flood_tracker[key] = times
return len(times) > FLOOD_THRESHOLD
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
@app.errorhandler(400)
def bad_request(e):
return jsonify({"error": "bad_request", "message": "Requête invalide."}), 400
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": "not_found", "message": "Ressource introuvable."}), 404
@app.errorhandler(405)
def method_not_allowed(e):
return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée."}), 405
@app.errorhandler(429)
def rate_limit_exceeded(e):
return jsonify({
@@ -152,12 +265,13 @@ def rate_limit_exceeded(e):
"message": "Trop de requêtes. Veuillez patienter avant de soumettre une nouvelle idée.",
}), 429
@app.errorhandler(500)
def internal_error(e):
logger.exception("Erreur interne non gérée")
return jsonify({"error": "internal_error", "message": "Erreur interne du serveur."}), 500
# ─── Routes ──────────────────────────────────────────────────────────────────
# ─── Routes publiques ─────────────────────────────────────────────────────────
@app.get("/api/healthz")
def health():
@@ -175,18 +289,25 @@ def list_ideas():
@app.get("/api/ideas/stats")
@limiter.limit("120 per minute")
def idea_stats():
"""Statistiques : total, acceptées, rejetées."""
"""Statistiques publiques : total, acceptées, rejetées."""
stats = get_stats()
return jsonify(stats)
@app.post("/api/ideas")
@limiter.limit("5 per minute; 20 per hour")
@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key)
def submit_idea():
"""
Soumet une idée citoyenne.
L'idée est filtrée par l'agent IA selon le cadre légal international
avant d'être intégrée dans la synthèse collective.
Protections anti-abus (dans l'ordre) :
1. Honeypot — rejet silencieux si champ leurre rempli
2. hCaptcha — vérification si HCAPTCHA_SECRET_KEY configuré
3. Cooldown cookie — rejet si soumission trop récente
4. Rate limiting — 3/heure par IP ou fingerprint (configurable)
5. Flood detection — alerte si > 10 soumissions / 5 min
6. Fingerprinting non-PII — hash de l'identifiant FingerprintJS
7. Filtrage IA — cadre légal international
"""
if not request.is_json:
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
@@ -195,6 +316,33 @@ def submit_idea():
if data is None:
return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400
# 1. Honeypot — si le champ leurre est rempli, c'est un bot
if data.get("_hp"):
logger.info("Honeypot déclenché — soumission ignorée silencieusement")
# Réponse 201 factice pour ne pas informer le bot
return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201
# 2. hCaptcha (stub — skip si HCAPTCHA_SECRET_KEY non configuré)
hcaptcha_token = (
(data.get("_h") or "")
or request.headers.get("X-HCaptcha-Token", "")
)
if not _verify_hcaptcha(hcaptcha_token):
logger.warning("hCaptcha échoué — IP: %s", get_remote_address())
return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée. Veuillez réessayer."}), 400
# 3. Cooldown cookie — protection contre les soumissions en rafale d'une même session
secret_key = os.environ.get("SECRET_KEY", "").strip()
if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
cv = request.cookies.get("_cv", "")
if cv and _verify_cooldown(cv, secret_key, CONTRIBUTION_COOLDOWN_SECONDS):
logger.info("Cooldown actif — soumission rejetée (même session)")
return jsonify({
"error": "cooldown",
"message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.",
}), 429
# 4. Validation et assainissement
validated, error = validate_idea_input(data)
if error:
return jsonify({"error": "validation_error", "message": error}), 400
@@ -202,26 +350,54 @@ def submit_idea():
content = validated["content"]
author = validated["author"]
logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content))
# 5. Extraction et hashage du fingerprint (non-PII)
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
fingerprint_hash = (
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None
)
# 6. Détection de flood
client_ip = get_remote_address()
if _check_flood(client_ip, fingerprint_hash):
logger.warning(
"ALERTE FLOOD — IP: %s | fingerprint: %s | seuil: %d/5min",
client_ip, fingerprint_hash, FLOOD_THRESHOLD,
)
# 7. Filtrage IA selon le cadre légal international
logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content))
filter_result = filter_idea(content)
accepted = bool(filter_result.get("accepted", False))
rejection_reason = filter_result.get("reason") if not accepted else None
legal_basis = filter_result.get("legal_basis") if not accepted else None
idea = insert_idea(content, author, accepted, rejection_reason, legal_basis)
idea = insert_idea(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
if accepted:
# Synthèse mise à jour en arrière-plan — ne bloque pas la réponse
threading.Thread(target=_update_synthesis_background, daemon=True).start()
return jsonify({
# Construction de la réponse
resp_data = {
"id": idea["id"],
"accepted": accepted,
"reason": rejection_reason,
"legalBasis": legal_basis if not accepted else None,
"idea": serialize_idea(idea),
}), 201
}
response = make_response(jsonify(resp_data), 201)
# Cookie cooldown httpOnly — marque la session comme ayant contribué
if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
response.set_cookie(
"_cv",
_sign_cooldown(secret_key),
max_age=CONTRIBUTION_COOLDOWN_SECONDS,
httponly=True,
samesite="Lax",
secure=request.is_secure,
)
return response
@app.get("/api/synthesis")
@@ -245,6 +421,7 @@ def get_synthesis_route():
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
})
# ─── Route publique : signalement ────────────────────────────────────────────
@app.post("/api/ideas/<int:idea_id>/flag")
@@ -378,7 +555,7 @@ def admin_export_csv():
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(["id", "content", "author", "accepted", "flagged",
"flag_count", "rejection_reason", "legal_basis",
"admin_note", "created_at"])
"admin_note", "fingerprint_hash", "created_at"])
for idea in ideas:
writer.writerow([
idea.get("id"),
@@ -390,6 +567,7 @@ def admin_export_csv():
idea.get("rejection_reason", ""),
idea.get("legal_basis", ""),
idea.get("admin_note", ""),
idea.get("fingerprint_hash", ""),
idea.get("created_at").isoformat() if idea.get("created_at") else "",
])
csv_bytes = output.getvalue().encode("utf-8-sig")
@@ -418,10 +596,11 @@ def serialize_idea(idea: dict) -> dict:
def serialize_idea_admin(idea: dict) -> dict:
base = serialize_idea(idea)
base["adminNote"] = idea.get("admin_note")
base["fingerprintHash"] = idea.get("fingerprint_hash")
return base
def _update_synthesis_background():
def _update_synthesis_background() -> None:
try:
ideas = get_accepted_ideas()
texts = [i["content"] for i in ideas]
@@ -437,5 +616,8 @@ if __name__ == "__main__":
port = int(os.environ.get("PORT", 8080))
logger.info("Initialisation de la base de données...")
init_db()
logger.info("La Voix du Peuple — Flask démarre sur le port %d", port)
logger.info(
"La Voix du Peuple — Flask démarre sur le port %d (storage: %s)",
port, "Redis" if _redis_url else "mémoire",
)
app.run(host="0.0.0.0", port=port, debug=False)
+29 -11
View File
@@ -1,5 +1,7 @@
"""
Couche d'accès à la base de données PostgreSQL.
La Voix du Peuple — Couche d'accès à la base de données PostgreSQL
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Utilise psycopg2 directement — pas d'ORM, code lisible et transparent.
"""
import os
@@ -32,7 +34,7 @@ def db_cursor():
conn.close()
def init_db():
def init_db() -> None:
"""Crée les tables si elles n'existent pas, et applique les migrations nécessaires."""
with db_cursor() as cur:
cur.execute("""
@@ -46,10 +48,12 @@ def init_db():
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
# Migrations incrémentales — idempotentes
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS fingerprint_hash VARCHAR(64)")
cur.execute("""
CREATE TABLE IF NOT EXISTS synthesis (
id SERIAL PRIMARY KEY,
@@ -61,16 +65,22 @@ def init_db():
logger.info("Base de données initialisée.")
def insert_idea(content: str, author: str | None, accepted: bool,
rejection_reason: str | None, legal_basis: str | None) -> dict:
def insert_idea(
content: str,
author: str | None,
accepted: bool,
rejection_reason: str | None,
legal_basis: str | None,
fingerprint_hash: str | None = None,
) -> dict:
with db_cursor() as cur:
cur.execute(
"""
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
""",
(content, author, accepted, rejection_reason, legal_basis),
(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash),
)
return dict(cur.fetchone())
@@ -91,8 +101,12 @@ def get_all_ideas(limit: int = 50) -> list[dict]:
return [dict(row) for row in cur.fetchall()]
def get_ideas_admin(status: str = "all", page: int = 1,
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
def get_ideas_admin(
status: str = "all",
page: int = 1,
per_page: int = 50,
search: str = "",
) -> tuple[list[dict], int]:
offset = (page - 1) * per_page
conditions = []
params: list = []
@@ -138,8 +152,12 @@ def bulk_delete_ideas(idea_ids: list[int]) -> int:
return len(cur.fetchall())
def override_idea(idea_id: int, accepted: bool,
reason: str | None, note: str | None) -> dict | None:
def override_idea(
idea_id: int,
accepted: bool,
reason: str | None,
note: str | None,
) -> dict | None:
with db_cursor() as cur:
cur.execute(
"""
+3
View File
@@ -1,4 +1,7 @@
"""
La Voix du Peuple — Cadre légal de référence
Copyright (C) 2026 billisdead — Licence EUPL-1.2
Base légale internationale ET française servant de référence pour le filtre éthique.
Sources internationales :
+1
View File
@@ -6,3 +6,4 @@ gunicorn>=23.0.0
openai>=1.77.0
psycopg2-binary>=2.9.10
python-dotenv>=1.0.1
redis>=5.0.0