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:
@@ -75,6 +75,8 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||
"@hcaptcha/react-hcaptcha": "^1.11.0",
|
||||
"qrcode.react": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user