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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user