Add automatic IP blacklisting after 2 illegal contributions

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 17:42:31 +02:00
parent 219292ea6c
commit 5c5152a387
5 changed files with 308 additions and 2 deletions
+76
View File
@@ -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/<ip_hash>")
@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: