From 5c5152a3876fd1dddc7d2dc891b548509ca55dad Mon Sep 17 00:00:00 2001 From: billisdead Date: Tue, 23 Jun 2026 17:42:31 +0200 Subject: [PATCH] Add automatic IP blacklisting after 2 illegal contributions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistence: PostgreSQL table ip_abuse (ip_hash SHA-256, rejection_count, timestamps, last_idea_id, blacklisted_at, expires_at). No raw IP stored. Logic: - First illegal contribution: recorded, tolerated (benefit of the doubt) - Second illegal contribution: 30-day block (IP_BLACKLIST_DAYS, configurable) - Counter is cumulative — valid contributions do not reset it - Block check fires before all other validations in both submit routes Backend: - database.py: check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist; ip_abuse table created in init_db() - app.py: _get_ip_hash() helper; blacklist check + record in submit_idea() and submit_consultation_idea(); admin routes GET/DELETE /api/admin/ip-blacklist Admin panel: new "Blacklist" tab showing active entries with hash, rejection count, trigger idea id, dates; "Lever" button for manual removal Docs: - SECURITE_ANTI_ABUS.md: section 8 describing the mechanism, RGPD basis, limits, and IP_BLACKLIST_DAYS env var - privacy-policy.tsx: IP row updated to reflect hash storage + legal basis Co-Authored-By: Claude Sonnet 4.6 --- artifacts/flask-api/app.py | 76 +++++++++++ artifacts/flask-api/database.py | 81 ++++++++++++ artifacts/voix-du-peuple/src/pages/admin.tsx | 121 +++++++++++++++++- .../src/pages/privacy-policy.tsx | 2 +- docs/SECURITE_ANTI_ABUS.md | 30 +++++ 5 files changed, 308 insertions(+), 2 deletions(-) diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 6715cb9..ab302b8 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -50,6 +50,7 @@ from database import ( create_consultation, get_consultation_by_slug, list_consultations, close_consultation, get_consultations_to_autoclose, get_consultation_stats, get_consultation_contributions, delete_consultation, + check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist, ) from ai_agent import filter_idea, synthesize_ideas @@ -72,6 +73,7 @@ CONTRIBUTION_COOLDOWN_SECONDS = int( CONSENT_TOKEN_TTL = 86400 # 24h — durée de validité du cookie de consentement FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10")) FLOOD_WINDOW_SECONDS = 300 +IP_BLACKLIST_DAYS = int(os.environ.get("IP_BLACKLIST_DAYS", "30")) _flood_tracker: dict[str, list[float]] = {} _flood_lock = threading.Lock() @@ -235,6 +237,11 @@ def get_fingerprint_key() -> str: return "ip:" + get_remote_address() +def _get_ip_hash() -> str: + """Hash SHA-256 (32 car.) de l'IP cliente — stocké à la place de l'IP brute (RGPD).""" + return hashlib.sha256(get_remote_address().encode()).hexdigest()[:32] + + def _sign_cooldown(secret: str) -> str: ts = int(time.time()) msg = ts.to_bytes(8, "big") @@ -471,6 +478,16 @@ def submit_idea(): if data is None: return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400 + if check_ip_blacklist(_get_ip_hash()): + logger.warning("Soumission bloquée — IP blacklistée: %s", get_remote_address()) + return jsonify({ + "error": "ip_blacklisted", + "message": ( + "Votre accès à cette plateforme est temporairement suspendu suite à des " + "soumissions de contenus contraires au cadre légal (RGPD art. 6(1)(f))." + ), + }), 403 + if data.get("_hp"): logger.info("Honeypot déclenché — soumission ignorée silencieusement") return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201 @@ -529,6 +546,15 @@ def submit_idea(): idea = insert_idea(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash) + if not accepted: + ip_hash = _get_ip_hash() + newly_blacklisted = record_ip_rejection(ip_hash, idea["id"], IP_BLACKLIST_DAYS) + if newly_blacklisted: + logger.warning( + "IP blacklistée %d jours — hash: %s | déclencheur: contribution #%d", + IP_BLACKLIST_DAYS, ip_hash, idea["id"], + ) + if accepted: threading.Thread(target=_update_synthesis_background, daemon=True).start() @@ -798,6 +824,16 @@ def submit_consultation_idea(slug: str): if data is None: return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400 + if check_ip_blacklist(_get_ip_hash()): + logger.warning("Soumission bloquée — IP blacklistée: %s (consultation: %s)", get_remote_address(), slug) + return jsonify({ + "error": "ip_blacklisted", + "message": ( + "Votre accès à cette plateforme est temporairement suspendu suite à des " + "soumissions de contenus contraires au cadre légal (RGPD art. 6(1)(f))." + ), + }), 403 + if data.get("_hp"): return jsonify({"id": 0, "accepted": True, "reason": None}), 201 @@ -828,6 +864,15 @@ def submit_consultation_idea(slug: str): fingerprint_hash, consultation["id"], ) + if not accepted: + ip_hash = _get_ip_hash() + newly_blacklisted = record_ip_rejection(ip_hash, idea["id"], IP_BLACKLIST_DAYS) + if newly_blacklisted: + logger.warning( + "IP blacklistée %d jours — hash: %s | déclencheur: contribution #%d (consultation: %s)", + IP_BLACKLIST_DAYS, ip_hash, idea["id"], slug, + ) + if accepted: threading.Thread( target=_update_synthesis_background, @@ -1171,6 +1216,37 @@ def admin_delete_consultation(slug: str): return jsonify({"ok": True}) +@app.get("/api/admin/ip-blacklist") +@require_admin +def admin_list_ip_blacklist(): + """Liste des IPs blacklistées actives (hash SHA-256, RGPD-compliant).""" + entries = get_ip_blacklist() + return jsonify([ + { + "ipHash": e["ip_hash"], + "rejectionCount": e["rejection_count"], + "firstRejectedAt": e["first_rejected_at"].isoformat() if e.get("first_rejected_at") else None, + "lastRejectedAt": e["last_rejected_at"].isoformat() if e.get("last_rejected_at") else None, + "lastIdeaId": e.get("last_idea_id"), + "blacklistedAt": e["blacklisted_at"].isoformat() if e.get("blacklisted_at") else None, + "expiresAt": e["expires_at"].isoformat() if e.get("expires_at") else None, + } + for e in entries + ]) + + +@app.delete("/api/admin/ip-blacklist/") +@require_admin +def admin_remove_ip_blacklist(ip_hash: str): + """Lève le blacklist d'une IP (supprime l'entrée de la table ip_abuse).""" + ip_hash = ip_hash.strip()[:64] + ok = remove_ip_blacklist(ip_hash) + if not ok: + return jsonify({"error": "not_found", "message": "Entrée introuvable."}), 404 + logger.info("Admin — blacklist IP levée — hash: %s", ip_hash) + return jsonify({"ok": True}) + + # ─── Helpers sérialiseurs ───────────────────────────────────────────────────── def serialize_idea(idea: dict) -> dict: diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index 1a4c548..e25bc46 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -101,6 +101,19 @@ def init_db() -> None: ) cur.execute("CREATE INDEX IF NOT EXISTS idx_ideas_consultation ON ideas(consultation_id)") cur.execute("CREATE INDEX IF NOT EXISTS idx_synthesis_consultation ON synthesis(consultation_id)") + # Table anti-abus — blacklist IP (RGPD art. 6(1)(f) — intérêt légitime) + cur.execute(""" + CREATE TABLE IF NOT EXISTS ip_abuse ( + ip_hash TEXT PRIMARY KEY, + rejection_count INTEGER NOT NULL DEFAULT 1, + first_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_idea_id INTEGER, + blacklisted_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ + ) + """) + cur.execute("CREATE INDEX IF NOT EXISTS idx_ip_abuse_expires ON ip_abuse(expires_at)") logger.info("Base de données initialisée.") @@ -486,6 +499,74 @@ def get_consultation_contributions( return rows, total +# ─── Anti-abus — Blacklist IP ───────────────────────────────────────────────── + +def check_ip_blacklist(ip_hash: str) -> bool: + """True si l'IP est blacklistée et que le blacklist n'a pas expiré.""" + with db_cursor() as cur: + cur.execute( + "SELECT 1 FROM ip_abuse WHERE ip_hash = %s AND blacklisted_at IS NOT NULL AND expires_at > NOW()", + (ip_hash,), + ) + return cur.fetchone() is not None + + +def record_ip_rejection(ip_hash: str, idea_id: int, blacklist_days: int) -> bool: + """ + Incrémente le compteur de rejets pour cette IP. + Déclenche le blacklist si rejection_count atteint 2. + Retourne True si le blacklist vient d'être activé. + """ + with db_cursor() as cur: + cur.execute( + """ + INSERT INTO ip_abuse (ip_hash, rejection_count, last_idea_id) + VALUES (%s, 1, %s) + ON CONFLICT (ip_hash) DO UPDATE + SET rejection_count = ip_abuse.rejection_count + 1, + last_rejected_at = NOW(), + last_idea_id = EXCLUDED.last_idea_id + RETURNING rejection_count, blacklisted_at + """, + (ip_hash, idea_id), + ) + row = dict(cur.fetchone()) + if row["rejection_count"] >= 2 and row["blacklisted_at"] is None: + cur.execute( + """ + UPDATE ip_abuse + SET blacklisted_at = NOW(), + expires_at = NOW() + (%s || ' days')::interval + WHERE ip_hash = %s + """, + (str(blacklist_days), ip_hash), + ) + return True + return False + + +def get_ip_blacklist() -> list[dict]: + """Liste des IPs blacklistées actives (non expirées), ordre anti-chronologique.""" + with db_cursor() as cur: + cur.execute( + """ + SELECT ip_hash, rejection_count, first_rejected_at, + last_rejected_at, last_idea_id, blacklisted_at, expires_at + FROM ip_abuse + WHERE blacklisted_at IS NOT NULL AND expires_at > NOW() + ORDER BY blacklisted_at DESC + """ + ) + return [dict(row) for row in cur.fetchall()] + + +def remove_ip_blacklist(ip_hash: str) -> bool: + """Lève le blacklist d'une IP (supprime l'entrée entièrement).""" + with db_cursor() as cur: + cur.execute("DELETE FROM ip_abuse WHERE ip_hash = %s RETURNING ip_hash", (ip_hash,)) + return cur.fetchone() is not None + + def delete_consultation(consultation_id: int) -> bool: """Supprime une consultation et toutes ses données (idées, synthèse) dans la même transaction.""" with db_cursor() as cur: diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx index c84188d..4affc66 100644 --- a/artifacts/voix-du-peuple/src/pages/admin.tsx +++ b/artifacts/voix-du-peuple/src/pages/admin.tsx @@ -27,6 +27,7 @@ import { Trash2, RefreshCw, Download, LogOut, Check, X, Flag, ChevronLeft, ChevronRight, Search, ShieldCheck, Eye, Loader2, Plus, Lock, Clock, Users, Building2, ExternalLink, Calendar, + ShieldBan, ShieldOff, } from "lucide-react"; const API_BASE = import.meta.env.VITE_API_URL ?? ""; @@ -68,6 +69,16 @@ type ConsultationStats = { lastUpdated: string | null; }; +type IpBlacklistEntry = { + ipHash: string; + rejectionCount: number; + firstRejectedAt: string | null; + lastRejectedAt: string | null; + lastIdeaId: number | null; + blacklistedAt: string | null; + expiresAt: string | null; +}; + type Consultation = { id: number; slug: string; @@ -546,13 +557,106 @@ function ConsultationsPanel({ headers, token }: { headers: Record }) { + const [entries, setEntries] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const { toast } = useToast(); + + const fetchEntries = React.useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`${API_BASE}/api/admin/ip-blacklist`, { headers }); + if (res.ok) setEntries(await res.json()); + } finally { + setLoading(false); + } + }, [headers]); + + React.useEffect(() => { fetchEntries(); }, [fetchEntries]); + + const lift = async (ipHash: string) => { + const res = await fetch(`${API_BASE}/api/admin/ip-blacklist/${ipHash}`, { + method: "DELETE", + headers, + }); + if (res.ok) { + toast({ title: "Blacklist levée", description: `Hash ${ipHash.slice(0, 8)}… retiré.` }); + fetchEntries(); + } else { + toast({ title: "Erreur", description: "Impossible de lever le blacklist.", variant: "destructive" }); + } + }; + + return ( +
+
+
+ +

IPs blacklistées

+ ({entries.length} active{entries.length !== 1 ? "s" : ""}) +
+ +
+ +
+

Les IPs sont stockées sous forme de hash SHA-256 (non réversible — RGPD art. 6(1)(f)).

+

Un blacklist est déclenché après 2 contributions illégales. Durée : 30 jours par défaut.

+
+ + {entries.length === 0 ? ( +
+ {loading ? : "Aucune IP blacklistée actuellement."} +
+ ) : ( +
+ {entries.map(e => ( +
+
+
+

+ hash{e.ipHash} +

+
+ {e.rejectionCount} rejet{e.rejectionCount !== 1 ? "s" : ""} + {e.lastIdeaId && ( + dernière contribution : #{e.lastIdeaId} + )} + {e.blacklistedAt && ( + depuis le {new Date(e.blacklistedAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} + )} + {e.expiresAt && ( + expire le {new Date(e.expiresAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })} + )} +
+
+ +
+
+ ))} +
+ )} +
+ ); +} + // ─── Composant principal Admin ──────────────────────────────────────────────── export default function Admin() { const { token, login, logout, headers } = useAdminAuth(); const { toast } = useToast(); - const [activePanel, setActivePanel] = useState<"contributions" | "consultations">("contributions"); + const [activePanel, setActivePanel] = useState<"contributions" | "consultations" | "blacklist">("contributions"); const [stats, setStats] = useState(null); const [list, setList] = useState(null); const [status, setStatus] = useState("all"); @@ -734,6 +838,16 @@ export default function Admin() { > Consultations +
@@ -763,6 +877,11 @@ export default function Admin() { )} + {/* ─── Panel Blacklist IP ─── */} + {activePanel === "blacklist" && token && ( + + )} + {/* ─── Panel Contributions ─── */} {activePanel === "contributions" && ( <> diff --git a/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx b/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx index a2adf97..cda94a8 100644 --- a/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx +++ b/artifacts/voix-du-peuple/src/pages/privacy-policy.tsx @@ -76,7 +76,7 @@ export default function PrivacyPolicy() { { d: "Date et heure", c: true, r: "Traçabilité, affichage dans l'interface" }, { d: "Hash technique anti-abus", c: true, r: "Protection contre les bots — non réversible, non-PII" }, { d: "Horodatage du consentement", c: true, r: "Preuve légale de votre accord (art. 7.1 RGPD)" }, - { d: "Adresse IP", c: false, r: "Non stockée — utilisée temporairement pour le rate limiting" }, + { d: "Adresse IP (hash)", c: true, r: "Hash SHA-256 non réversible — anti-abus : blocage temporaire après 2 soumissions illégales (RGPD art. 6(1)(f)), conservé 30 jours" }, { d: "Cookies de suivi", c: false, r: "Aucun tracker tiers" }, { d: "Compte utilisateur", c: false, r: "Aucune inscription requise" }, { d: "Géolocalisation", c: false, r: "—" }, diff --git a/docs/SECURITE_ANTI_ABUS.md b/docs/SECURITE_ANTI_ABUS.md index 841ccc0..18e0b9d 100644 --- a/docs/SECURITE_ANTI_ABUS.md +++ b/docs/SECURITE_ANTI_ABUS.md @@ -158,6 +158,33 @@ WARNING ALERTE FLOOD — IP: 1.2.3.4 | fingerprint: abc123... | seuil: 10/5min --- +### 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/` + +**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 ```env @@ -175,6 +202,9 @@ 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 ``` ---