Licence EUPL-1.2 + hardening anti-abus

P1 — Licence :
- Ajout du fichier LICENSE (EUPL-1.2 complet)
- README mis à jour : section licence, table docs, vars d'environnement
- En-têtes EUPL ajoutés dans les fichiers sources principaux (Flask, React)

P2 — Hardening anti-abus :
- Rate limiting Redis-ready (REDIS_URL) avec clé fingerprint + IP
- Honeypot anti-bot : champ caché côté client + vérification serveur
- Fingerprinting non-PII via FingerprintJS (hash SHA-256, colonne ideas.fingerprint_hash)
- Cooldown session : cookie httpOnly signé HMAC-SHA256 (SECRET_KEY requis)
- Détection de flood : alerte WARNING si > FLOOD_THRESHOLD soumissions / 5 min
- hCaptcha stub : intégré, activable via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY
- Nouvelles dépendances : redis (backend), @fingerprintjs/fingerprintjs + @hcaptcha/react-hcaptcha (frontend)
- docs/SECURITE_ANTI_ABUS.md : documentation complète des seuils et de la configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 18:05:46 +02:00
parent 57211ad393
commit 45edc1fa77
14 changed files with 881 additions and 38 deletions
+2
View File
@@ -75,6 +75,8 @@
"zod": "catalog:"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^4.5.1",
"@hcaptcha/react-hcaptcha": "^1.11.0",
"qrcode.react": "^4.2.0"
}
}
+16
View File
@@ -1,3 +1,5 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react";
import { Switch, Route, Router as WouterRouter, Link } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
@@ -10,6 +12,8 @@ import Flyer from "@/pages/flyer";
import Admin from "@/pages/admin";
import { AccessibilityProvider } from "@/hooks/use-accessibility";
import { AccessibilityPanel } from "@/components/accessibility-panel";
import { setVisitorId } from "@workspace/api-client-react";
import FingerprintJS from "@fingerprintjs/fingerprintjs";
const queryClient = new QueryClient({
defaultOptions: {
@@ -77,6 +81,18 @@ function Router() {
}
function App() {
// Initialise FingerprintJS une seule fois au chargement
// L'identifiant de visite est envoyé sur chaque appel API (header X-Visitor-Id)
// Il est hashé côté serveur avant stockage — aucune donnée PII conservée
React.useEffect(() => {
FingerprintJS.load()
.then((fp) => fp.get())
.then((result) => setVisitorId(result.visitorId))
.catch(() => {
// Dégradation silencieuse si FingerprintJS indisponible
});
}, []);
return (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
+1
View File
@@ -1,3 +1,4 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
+70 -1
View File
@@ -1,3 +1,4 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -5,6 +6,7 @@ import { z } from "zod";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useQueryClient } from "@tanstack/react-query";
import HCaptcha from "@hcaptcha/react-hcaptcha";
import {
useSubmitIdea,
useListIdeas,
@@ -12,6 +14,8 @@ import {
useGetSynthesis,
getListIdeasQueryKey,
getGetIdeaStatsQueryKey,
addExtraHeader,
removeExtraHeader,
} from "@workspace/api-client-react";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
@@ -71,6 +75,9 @@ const VALEURS = [
},
];
// Clé hCaptcha — activée si la variable d'environnement est définie
const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined;
const API_BASE = import.meta.env.VITE_API_URL ?? "";
export default function Home() {
@@ -84,6 +91,13 @@ export default function Home() {
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
// Ref pour le champ leurre honeypot — invisible, non relié à react-hook-form
const honeypotRef = React.useRef<HTMLInputElement>(null);
// hCaptcha — widget et token
const captchaRef = React.useRef<HCaptcha>(null);
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
const handleFlag = async (ideaId: number) => {
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
setFlaggingId(ideaId);
@@ -173,6 +187,29 @@ export default function Home() {
});
const onSubmit = (data: SubmitIdeaValues) => {
// Honeypot — si le champ leurre est rempli, c'est un bot
if (honeypotRef.current?.value) {
// Simulation silencieuse d'un succès sans appel API
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
form.reset();
return;
}
// hCaptcha — obligatoire si la clé de site est configurée
if (HCAPTCHA_SITE_KEY && !captchaToken) {
toast({
title: "Vérification requise",
description: "Veuillez valider le CAPTCHA avant de soumettre.",
variant: "destructive",
});
return;
}
// Transmission du token hCaptcha si disponible
if (captchaToken) {
addExtraHeader("x-hcaptcha-token", captchaToken);
}
setSubmitResult(null);
submitIdea.mutate({ data }, {
onSuccess: (result) => {
@@ -198,6 +235,12 @@ export default function Home() {
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
});
},
onSettled: () => {
// Nettoyage du token hCaptcha après chaque tentative
removeExtraHeader("x-hcaptcha-token");
captchaRef.current?.resetCaptcha();
setCaptchaToken(null);
},
});
};
@@ -227,6 +270,17 @@ export default function Home() {
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Champ leurre anti-bot (honeypot) — invisible, ne jamais supprimer */}
<input
ref={honeypotRef}
type="text"
name="_hp"
aria-hidden="true"
tabIndex={-1}
autoComplete="off"
style={{ display: "none", position: "absolute", left: "-9999px" }}
/>
<FormField
control={form.control}
name="content"
@@ -268,7 +322,7 @@ export default function Home() {
<Button
type="submit"
className="font-bold tracking-wide flex-shrink-0"
disabled={submitIdea.isPending}
disabled={submitIdea.isPending || (HCAPTCHA_SITE_KEY ? !captchaToken : false)}
data-testid="button-submit-idea"
>
{submitIdea.isPending ? (
@@ -278,6 +332,21 @@ export default function Home() {
)}
</Button>
</div>
{/* hCaptcha — activé uniquement si VITE_HCAPTCHA_SITE_KEY est défini */}
{HCAPTCHA_SITE_KEY && (
<div className="flex justify-start">
<HCaptcha
ref={captchaRef}
sitekey={HCAPTCHA_SITE_KEY}
onVerify={(token) => setCaptchaToken(token)}
onExpire={() => setCaptchaToken(null)}
onError={() => setCaptchaToken(null)}
size="compact"
languageOverride="fr"
/>
</div>
)}
</form>
</Form>