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:
+81
View File
@@ -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:
+120 -1
View File
@@ -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<string, string
);
}
// ─── Panel Blacklist IP ───────────────────────────────────────────────────────
function BlacklistPanel({ headers }: { headers: Record<string, string> }) {
const [entries, setEntries] = React.useState<IpBlacklistEntry[]>([]);
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldBan className="h-4 w-4 text-destructive" />
<h2 className="font-serif font-semibold text-lg text-primary">IPs blacklistées</h2>
<span className="text-xs text-muted-foreground font-mono">({entries.length} active{entries.length !== 1 ? "s" : ""})</span>
</div>
<Button size="sm" variant="outline" onClick={fetchEntries} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
</div>
<div className="text-xs text-muted-foreground border border-border/40 rounded-md p-3 bg-muted/30 space-y-1">
<p>Les IPs sont stockées sous forme de hash SHA-256 (non réversible RGPD art. 6(1)(f)).</p>
<p>Un blacklist est déclenché après <strong>2 contributions illégales</strong>. Durée : <strong>30 jours</strong> par défaut.</p>
</div>
{entries.length === 0 ? (
<div className="text-center py-12 text-muted-foreground text-sm">
{loading ? <Loader2 className="h-5 w-5 animate-spin mx-auto" /> : "Aucune IP blacklistée actuellement."}
</div>
) : (
<div className="space-y-2">
{entries.map(e => (
<div key={e.ipHash} className="border border-border rounded-lg p-4 bg-card space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="font-mono text-xs text-foreground/80 break-all">
<span className="text-muted-foreground mr-2">hash</span>{e.ipHash}
</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span><span className="text-destructive font-semibold">{e.rejectionCount}</span> rejet{e.rejectionCount !== 1 ? "s" : ""}</span>
{e.lastIdeaId && (
<span>dernière contribution : <span className="font-mono">#{e.lastIdeaId}</span></span>
)}
{e.blacklistedAt && (
<span>depuis le {new Date(e.blacklistedAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
)}
{e.expiresAt && (
<span>expire le {new Date(e.expiresAt).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })}</span>
)}
</div>
</div>
<Button
size="sm"
variant="outline"
className="flex-shrink-0 text-xs gap-1.5"
onClick={() => lift(e.ipHash)}
>
<ShieldOff className="h-3.5 w-3.5" /> Lever
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}
// ─── 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<Stats | null>(null);
const [list, setList] = useState<IdeaList | null>(null);
const [status, setStatus] = useState("all");
@@ -734,6 +838,16 @@ export default function Admin() {
>
Consultations
</button>
<button
onClick={() => setActivePanel("blacklist")}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors flex items-center gap-1 ${
activePanel === "blacklist"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
<ShieldBan className="h-3 w-3" /> Blacklist
</button>
</div>
<div className="ml-auto flex items-center gap-2">
@@ -763,6 +877,11 @@ export default function Admin() {
<ConsultationsPanel headers={headers} token={token} />
)}
{/* ─── Panel Blacklist IP ─── */}
{activePanel === "blacklist" && token && (
<BlacklistPanel headers={headers} />
)}
{/* ─── Panel Contributions ─── */}
{activePanel === "contributions" && (
<>
@@ -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: "—" },