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