diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e92fc84 --- /dev/null +++ b/LICENSE @@ -0,0 +1,278 @@ +EUROPEAN UNION PUBLIC LICENCE v. 1.2 +EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the 'EUPL') applies to the Work (as +defined below) which is provided under the terms of this Licence. Any use of +the Work, other than as authorised under this Licence is prohibited (to the +extent such use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- 'The Licence': this Licence. +- 'The Original Work': the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. +- 'Derivative Works': the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. +- 'The Work': the Original Work or its Derivative Works. +- 'The Source Code': the human-readable form of the Work which is the most + convenient for people to study and modify. +- 'The Executable Code': any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. +- 'The Licensor': the natural or legal person that distributes or communicates + the Work under the Licence. +- 'Contributor(s)': any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. +- 'The Licensee' or 'You': any natural or legal person who makes any usage of + the Work under the terms of the Licence. +- 'Distribution' or 'Communication': any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case + may be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work it distributes or communicates. +The Licensee must cause any Derivative Work to carry prominent notices stating +that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating 'EUPL v. 1.2 only'. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the purpose of this clause, 'Compatible +Licence' refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible Licence +shall prevail. + +Provision of Source Code: If the Licensee distributes or communicates copies of +the Work, he/she will provide a machine-readable copy of the Source Code or +indicate a repository where this Source will be easily and freely available for +as long as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +contributors. It is not a finished work and may therefore contain defects or +'bugs' inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an 'as is' basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial damage, +even if the Licensor has been advised of the possibility of such damage. However, +the Licensor will be liable under statutory product liability laws as far such +laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by the +fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon 'I agree' +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the Public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it can be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +'Compatible Licences' according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+) + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/README.md b/README.md index 5a285a9..2f2ce83 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,28 @@ Le site écoute sur le **port HTTP 8080**. Vous gérez le HTTPS en amont. ## Variables d'environnement clés ```env +# Base de données DATABASE_URL=postgresql://user:pass@localhost:5432/voix_du_peuple + +# IA (Mistral recommandé — souveraineté européenne) MISTRAL_API_KEY=sk-... -SESSION_SECRET=une-longue-chaine-aleatoire + +# Sécurité (obligatoire en production) +SECRET_KEY=une-longue-chaine-aleatoire-minimum-32-chars +ADMIN_SECRET=votre-mot-de-passe-admin + +# Anti-abus (optionnel — valeurs par défaut raisonnables) +REDIS_URL=redis://localhost:6379/0 +RATE_LIMIT_CONTRIBUTIONS=5 per minute;3 per hour +CONTRIBUTION_COOLDOWN_SECONDS=3600 +FLOOD_THRESHOLD=10 + +# hCaptcha (optionnel — recommandé en production) +HCAPTCHA_SECRET_KEY=votre-cle-secrete + +# Frontend VITE_APP_URL=https://votredomaine.fr +VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site # Nécessite rebuild frontend ``` --- @@ -130,9 +148,18 @@ Prérequis : secret `GITEA_TOKEN` configuré dans Replit → Secrets. | [`docs/WIKI.md`](docs/WIKI.md) | Page wiki — présentation générale | | [`docs/INSTALL_ROCKY.md`](docs/INSTALL_ROCKY.md) | Installation sur RockyLinux 9 | | [`docs/GITEA_TUTO.md`](docs/GITEA_TUTO.md) | Synchronisation Replit → Gitea | +| [`docs/SECURITE_ANTI_ABUS.md`](docs/SECURITE_ANTI_ABUS.md) | Protections anti-bot, flood, rate limiting, hCaptcha | --- ## Licence -Projet personnel — tous droits réservés. Contactez l'auteur pour toute utilisation ou réutilisation. +Ce projet est publié sous **[European Union Public Licence v. 1.2 (EUPL-1.2)](LICENSE)**. + +L'EUPL-1.2 est la licence open source officielle de l'Union européenne. Elle est : +- **Compatible** avec la GPL v2/v3, l'AGPL v3 et la MPL 2.0 (cf. Appendice) +- **Reconnue** par la Commission européenne et les institutions publiques de l'UE +- **Adaptée** aux projets civiques et associatifs souhaitant une réutilisation libre sous condition de réciprocité (copyleft) +- **Disponible** en 23 langues officielles de l'UE — voir [joinup.ec.europa.eu](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12) + +Ce choix est cohérent avec la posture souveraineté numérique européenne du projet et permet à toute association, mairie ou collectif de reprendre et déployer cet outil sous les mêmes conditions. diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py index 0e53dac..2608d22 100644 --- a/artifacts/flask-api/ai_agent.py +++ b/artifacts/flask-api/ai_agent.py @@ -1,4 +1,7 @@ """ +La Voix du Peuple — Agent IA +Copyright (C) 2026 billisdead — Licence EUPL-1.2 + Agent IA pour le filtrage éthique et la synthèse démocratique. Supporte Mistral AI, OpenAI, et les intégrations Replit AI. """ diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index b48bedd..fd43675 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -1,29 +1,41 @@ """ La Voix du Peuple — Backend Flask ================================== +Copyright (C) 2026 billisdead +Licence : European Union Public Licence v. 1.2 (EUPL-1.2) + Plateforme démocratique citoyenne. Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000), Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024. Sécurité : -- Rate limiting (flask-limiter) +- Rate limiting IP + fingerprint (flask-limiter, Redis si REDIS_URL défini) +- Honeypot anti-bot (champ caché + vérification serveur) +- Fingerprinting non-PII (FingerprintJS hash SHA-256, sans cookie tiers) +- Détection de flood (> FLOOD_THRESHOLD soumissions / 5 min / même IP) +- Cooldown session (cookie httpOnly signé HMAC-SHA256, si SECRET_KEY défini) +- hCaptcha stub (activer via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY) - Validation et assainissement des entrées (bleach) - CORS restreint -- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.) -- Protection contre l'injection via requêtes paramétrées (psycopg2) +- En-têtes de sécurité HTTP - Panel admin protégé par ADMIN_SECRET (Bearer token) -- Aucun secret exposé dans les réponses d'erreur """ import csv +import hashlib +import hmac import io -import os +import json import logging +import os import threading +import time +import urllib.parse +import urllib.request from functools import wraps import bleach -from flask import Flask, jsonify, request, Response +from flask import Flask, jsonify, make_response, request, Response from flask_cors import CORS from flask_limiter import Limiter from flask_limiter.util import get_remote_address @@ -43,27 +55,50 @@ logging.basicConfig( ) logger = logging.getLogger(__name__) +# ─── Constantes anti-abus ──────────────────────────────────────────────────── + +# Rate limit pour POST /api/ideas (format flask-limiter) +RATE_LIMIT_CONTRIBUTIONS = os.environ.get( + "RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour" +) + +# Cooldown entre deux soumissions d'une même session (secondes) +CONTRIBUTION_COOLDOWN_SECONDS = int( + os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "3600") +) + +# Seuil de flood : nombre de soumissions / 5 min / IP avant alerte +FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10")) +FLOOD_WINDOW_SECONDS = 300 # 5 minutes + +# État interne flood detection (en mémoire, reset au redémarrage) +_flood_tracker: dict[str, list[float]] = {} +_flood_lock = threading.Lock() + # ─── Application ──────────────────────────────────────────────────────────── app = Flask(__name__) app.config["JSON_SORT_KEYS"] = False -# CORS : autorise uniquement les origines du même domaine Replit +# CORS : autorise uniquement les origines du même domaine en production CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False) -# Rate limiting — protection anti-spam et anti-DDoS +# Storage Redis si disponible, sinon mémoire (dev / instance unique) +_redis_url = os.environ.get("REDIS_URL", "") +_storage_uri = _redis_url if _redis_url else "memory://" + limiter = Limiter( get_remote_address, app=app, default_limits=["200 per day", "60 per hour"], - storage_uri="memory://", + storage_uri=_storage_uri, strategy="fixed-window", ) # ─── En-têtes de sécurité HTTP ─────────────────────────────────────────────── @app.after_request -def set_security_headers(response): +def set_security_headers(response: Response) -> Response: response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" @@ -83,10 +118,12 @@ CONTENT_MIN = 10 CONTENT_MAX = 1000 AUTHOR_MAX = 100 + def sanitize_text(text: str) -> str: """Supprime tout HTML/JavaScript — protection XSS.""" return bleach.clean(text, tags=[], strip=True).strip() + def validate_idea_input(data: dict) -> tuple[dict | None, str | None]: """Valide et assainit les données de soumission d'une idée.""" content = data.get("content") @@ -119,6 +156,7 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]: def _get_admin_secret() -> str | None: return os.environ.get("ADMIN_SECRET") + def require_admin(f): @wraps(f) def decorated(*args, **kwargs): @@ -131,20 +169,95 @@ def require_admin(f): return f(*args, **kwargs) return decorated +# ─── Helpers anti-abus ─────────────────────────────────────────────────────── + +def get_fingerprint_key() -> str: + """Clé de rate limiting : fingerprint hashé si présent, sinon IP.""" + visitor_id = request.headers.get("X-Visitor-Id", "").strip() + if visitor_id: + return "fp:" + hashlib.sha256(visitor_id.encode()).hexdigest()[:32] + return "ip:" + get_remote_address() + + +def _sign_cooldown(secret: str) -> str: + """Génère un token de cooldown signé HMAC-SHA256.""" + ts = int(time.time()) + msg = ts.to_bytes(8, "big") + sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] + return f"{ts}.{sig}" + + +def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool: + """Retourne True si le cooldown est encore actif pour ce cookie.""" + try: + ts_str, sig = cookie.rsplit(".", 1) + ts = int(ts_str) + msg = ts.to_bytes(8, "big") + expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] + if not hmac.compare_digest(sig, expected): + return False + return (time.time() - ts) < cooldown_seconds + except Exception: + return False + + +def _verify_hcaptcha(token: str) -> bool: + """ + Vérifie un token hCaptcha. + Renvoie True si HCAPTCHA_SECRET_KEY n'est pas configuré (stub désactivé). + """ + secret = os.environ.get("HCAPTCHA_SECRET_KEY", "").strip() + if not secret: + return True # Stub désactivé — pas de clé configurée + if not token: + return False + try: + payload = urllib.parse.urlencode({"secret": secret, "response": token}).encode() + req = urllib.request.Request( + "https://hcaptcha.com/siteverify", + data=payload, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read()) + return bool(result.get("success", False)) + except Exception: + logger.warning("Vérification hCaptcha impossible (erreur réseau) — skip") + return True # En cas d'erreur réseau, on ne bloque pas + + +def _check_flood(ip: str, fingerprint_hash: str | None) -> bool: + """ + Enregistre la soumission et retourne True si le seuil de flood est dépassé. + Utilise l'empreinte si disponible, sinon l'IP seule. + """ + key = fingerprint_hash if fingerprint_hash else ip + now = time.time() + with _flood_lock: + times = _flood_tracker.get(key, []) + # Nettoyage des timestamps expirés + times = [t for t in times if now - t < FLOOD_WINDOW_SECONDS] + times.append(now) + _flood_tracker[key] = times + return len(times) > FLOOD_THRESHOLD + # ─── Gestion des erreurs ───────────────────────────────────────────────────── @app.errorhandler(400) def bad_request(e): return jsonify({"error": "bad_request", "message": "Requête invalide."}), 400 + @app.errorhandler(404) def not_found(e): return jsonify({"error": "not_found", "message": "Ressource introuvable."}), 404 + @app.errorhandler(405) def method_not_allowed(e): return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée."}), 405 + @app.errorhandler(429) def rate_limit_exceeded(e): return jsonify({ @@ -152,12 +265,13 @@ def rate_limit_exceeded(e): "message": "Trop de requêtes. Veuillez patienter avant de soumettre une nouvelle idée.", }), 429 + @app.errorhandler(500) def internal_error(e): logger.exception("Erreur interne non gérée") return jsonify({"error": "internal_error", "message": "Erreur interne du serveur."}), 500 -# ─── Routes ────────────────────────────────────────────────────────────────── +# ─── Routes publiques ───────────────────────────────────────────────────────── @app.get("/api/healthz") def health(): @@ -175,18 +289,25 @@ def list_ideas(): @app.get("/api/ideas/stats") @limiter.limit("120 per minute") def idea_stats(): - """Statistiques : total, acceptées, rejetées.""" + """Statistiques publiques : total, acceptées, rejetées.""" stats = get_stats() return jsonify(stats) @app.post("/api/ideas") -@limiter.limit("5 per minute; 20 per hour") +@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key) def submit_idea(): """ Soumet une idée citoyenne. - L'idée est filtrée par l'agent IA selon le cadre légal international - avant d'être intégrée dans la synthèse collective. + + Protections anti-abus (dans l'ordre) : + 1. Honeypot — rejet silencieux si champ leurre rempli + 2. hCaptcha — vérification si HCAPTCHA_SECRET_KEY configuré + 3. Cooldown cookie — rejet si soumission trop récente + 4. Rate limiting — 3/heure par IP ou fingerprint (configurable) + 5. Flood detection — alerte si > 10 soumissions / 5 min + 6. Fingerprinting non-PII — hash de l'identifiant FingerprintJS + 7. Filtrage IA — cadre légal international """ if not request.is_json: return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400 @@ -195,6 +316,33 @@ def submit_idea(): if data is None: return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400 + # 1. Honeypot — si le champ leurre est rempli, c'est un bot + if data.get("_hp"): + logger.info("Honeypot déclenché — soumission ignorée silencieusement") + # Réponse 201 factice pour ne pas informer le bot + return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201 + + # 2. hCaptcha (stub — skip si HCAPTCHA_SECRET_KEY non configuré) + hcaptcha_token = ( + (data.get("_h") or "") + or request.headers.get("X-HCaptcha-Token", "") + ) + if not _verify_hcaptcha(hcaptcha_token): + logger.warning("hCaptcha échoué — IP: %s", get_remote_address()) + return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée. Veuillez réessayer."}), 400 + + # 3. Cooldown cookie — protection contre les soumissions en rafale d'une même session + secret_key = os.environ.get("SECRET_KEY", "").strip() + if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key: + cv = request.cookies.get("_cv", "") + if cv and _verify_cooldown(cv, secret_key, CONTRIBUTION_COOLDOWN_SECONDS): + logger.info("Cooldown actif — soumission rejetée (même session)") + return jsonify({ + "error": "cooldown", + "message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.", + }), 429 + + # 4. Validation et assainissement validated, error = validate_idea_input(data) if error: return jsonify({"error": "validation_error", "message": error}), 400 @@ -202,26 +350,54 @@ def submit_idea(): content = validated["content"] author = validated["author"] - logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content)) + # 5. Extraction et hashage du fingerprint (non-PII) + raw_fp = request.headers.get("X-Visitor-Id", "").strip() + fingerprint_hash = ( + hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None + ) + # 6. Détection de flood + client_ip = get_remote_address() + if _check_flood(client_ip, fingerprint_hash): + logger.warning( + "ALERTE FLOOD — IP: %s | fingerprint: %s | seuil: %d/5min", + client_ip, fingerprint_hash, FLOOD_THRESHOLD, + ) + + # 7. Filtrage IA selon le cadre légal international + logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content)) filter_result = filter_idea(content) accepted = bool(filter_result.get("accepted", False)) rejection_reason = filter_result.get("reason") if not accepted else None legal_basis = filter_result.get("legal_basis") if not accepted else None - idea = insert_idea(content, author, accepted, rejection_reason, legal_basis) + idea = insert_idea(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash) if accepted: - # Synthèse mise à jour en arrière-plan — ne bloque pas la réponse threading.Thread(target=_update_synthesis_background, daemon=True).start() - return jsonify({ + # Construction de la réponse + resp_data = { "id": idea["id"], "accepted": accepted, "reason": rejection_reason, "legalBasis": legal_basis if not accepted else None, "idea": serialize_idea(idea), - }), 201 + } + response = make_response(jsonify(resp_data), 201) + + # Cookie cooldown httpOnly — marque la session comme ayant contribué + if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key: + response.set_cookie( + "_cv", + _sign_cooldown(secret_key), + max_age=CONTRIBUTION_COOLDOWN_SECONDS, + httponly=True, + samesite="Lax", + secure=request.is_secure, + ) + + return response @app.get("/api/synthesis") @@ -245,6 +421,7 @@ def get_synthesis_route(): "updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None, }) + # ─── Route publique : signalement ──────────────────────────────────────────── @app.post("/api/ideas//flag") @@ -378,7 +555,7 @@ def admin_export_csv(): writer = csv.writer(output, quoting=csv.QUOTE_ALL) writer.writerow(["id", "content", "author", "accepted", "flagged", "flag_count", "rejection_reason", "legal_basis", - "admin_note", "created_at"]) + "admin_note", "fingerprint_hash", "created_at"]) for idea in ideas: writer.writerow([ idea.get("id"), @@ -390,6 +567,7 @@ def admin_export_csv(): idea.get("rejection_reason", ""), idea.get("legal_basis", ""), idea.get("admin_note", ""), + idea.get("fingerprint_hash", ""), idea.get("created_at").isoformat() if idea.get("created_at") else "", ]) csv_bytes = output.getvalue().encode("utf-8-sig") @@ -418,10 +596,11 @@ def serialize_idea(idea: dict) -> dict: def serialize_idea_admin(idea: dict) -> dict: base = serialize_idea(idea) base["adminNote"] = idea.get("admin_note") + base["fingerprintHash"] = idea.get("fingerprint_hash") return base -def _update_synthesis_background(): +def _update_synthesis_background() -> None: try: ideas = get_accepted_ideas() texts = [i["content"] for i in ideas] @@ -437,5 +616,8 @@ if __name__ == "__main__": port = int(os.environ.get("PORT", 8080)) logger.info("Initialisation de la base de données...") init_db() - logger.info("La Voix du Peuple — Flask démarre sur le port %d", port) + logger.info( + "La Voix du Peuple — Flask démarre sur le port %d (storage: %s)", + port, "Redis" if _redis_url else "mémoire", + ) app.run(host="0.0.0.0", port=port, debug=False) diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index 25253ba..207612a 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -1,5 +1,7 @@ """ -Couche d'accès à la base de données PostgreSQL. +La Voix du Peuple — Couche d'accès à la base de données PostgreSQL +Copyright (C) 2026 billisdead — Licence EUPL-1.2 + Utilise psycopg2 directement — pas d'ORM, code lisible et transparent. """ import os @@ -32,7 +34,7 @@ def db_cursor(): conn.close() -def init_db(): +def init_db() -> None: """Crée les tables si elles n'existent pas, et applique les migrations nécessaires.""" with db_cursor() as cur: cur.execute(""" @@ -46,10 +48,12 @@ def init_db(): created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) """) + # Migrations incrémentales — idempotentes cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0") cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT") + cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS fingerprint_hash VARCHAR(64)") cur.execute(""" CREATE TABLE IF NOT EXISTS synthesis ( id SERIAL PRIMARY KEY, @@ -61,16 +65,22 @@ def init_db(): logger.info("Base de données initialisée.") -def insert_idea(content: str, author: str | None, accepted: bool, - rejection_reason: str | None, legal_basis: str | None) -> dict: +def insert_idea( + content: str, + author: str | None, + accepted: bool, + rejection_reason: str | None, + legal_basis: str | None, + fingerprint_hash: str | None = None, +) -> dict: with db_cursor() as cur: cur.execute( """ - INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING * """, - (content, author, accepted, rejection_reason, legal_basis), + (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash), ) return dict(cur.fetchone()) @@ -91,8 +101,12 @@ def get_all_ideas(limit: int = 50) -> list[dict]: return [dict(row) for row in cur.fetchall()] -def get_ideas_admin(status: str = "all", page: int = 1, - per_page: int = 50, search: str = "") -> tuple[list[dict], int]: +def get_ideas_admin( + status: str = "all", + page: int = 1, + per_page: int = 50, + search: str = "", +) -> tuple[list[dict], int]: offset = (page - 1) * per_page conditions = [] params: list = [] @@ -138,8 +152,12 @@ def bulk_delete_ideas(idea_ids: list[int]) -> int: return len(cur.fetchall()) -def override_idea(idea_id: int, accepted: bool, - reason: str | None, note: str | None) -> dict | None: +def override_idea( + idea_id: int, + accepted: bool, + reason: str | None, + note: str | None, +) -> dict | None: with db_cursor() as cur: cur.execute( """ diff --git a/artifacts/flask-api/legal_framework.py b/artifacts/flask-api/legal_framework.py index 42e73be..119f29c 100644 --- a/artifacts/flask-api/legal_framework.py +++ b/artifacts/flask-api/legal_framework.py @@ -1,4 +1,7 @@ """ +La Voix du Peuple — Cadre légal de référence +Copyright (C) 2026 billisdead — Licence EUPL-1.2 + Base légale internationale ET française servant de référence pour le filtre éthique. Sources internationales : diff --git a/artifacts/flask-api/requirements.txt b/artifacts/flask-api/requirements.txt index a078b90..e8c9b09 100644 --- a/artifacts/flask-api/requirements.txt +++ b/artifacts/flask-api/requirements.txt @@ -6,3 +6,4 @@ gunicorn>=23.0.0 openai>=1.77.0 psycopg2-binary>=2.9.10 python-dotenv>=1.0.1 +redis>=5.0.0 diff --git a/artifacts/voix-du-peuple/package.json b/artifacts/voix-du-peuple/package.json index ce136a3..bb07ba5 100644 --- a/artifacts/voix-du-peuple/package.json +++ b/artifacts/voix-du-peuple/package.json @@ -75,6 +75,8 @@ "zod": "catalog:" }, "dependencies": { + "@fingerprintjs/fingerprintjs": "^4.5.1", + "@hcaptcha/react-hcaptcha": "^1.11.0", "qrcode.react": "^4.2.0" } } diff --git a/artifacts/voix-du-peuple/src/App.tsx b/artifacts/voix-du-peuple/src/App.tsx index 6e414ca..14ab809 100644 --- a/artifacts/voix-du-peuple/src/App.tsx +++ b/artifacts/voix-du-peuple/src/App.tsx @@ -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 ( diff --git a/artifacts/voix-du-peuple/src/main.tsx b/artifacts/voix-du-peuple/src/main.tsx index 696e0d2..66196d9 100644 --- a/artifacts/voix-du-peuple/src/main.tsx +++ b/artifacts/voix-du-peuple/src/main.tsx @@ -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"; diff --git a/artifacts/voix-du-peuple/src/pages/home.tsx b/artifacts/voix-du-peuple/src/pages/home.tsx index b771d0f..aff7338 100644 --- a/artifacts/voix-du-peuple/src/pages/home.tsx +++ b/artifacts/voix-du-peuple/src/pages/home.tsx @@ -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>(new Set()); const [flaggingId, setFlaggingId] = React.useState(null); + // Ref pour le champ leurre honeypot — invisible, non relié à react-hook-form + const honeypotRef = React.useRef(null); + + // hCaptcha — widget et token + const captchaRef = React.useRef(null); + const [captchaToken, setCaptchaToken] = React.useState(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() {
+ {/* Champ leurre anti-bot (honeypot) — invisible, ne jamais supprimer */} + + {submitIdea.isPending ? ( @@ -278,6 +332,21 @@ export default function Home() { )} + + {/* hCaptcha — activé uniquement si VITE_HCAPTCHA_SITE_KEY est défini */} + {HCAPTCHA_SITE_KEY && ( +
+ setCaptchaToken(token)} + onExpire={() => setCaptchaToken(null)} + onError={() => setCaptchaToken(null)} + size="compact" + languageOverride="fr" + /> +
+ )} diff --git a/docs/SECURITE_ANTI_ABUS.md b/docs/SECURITE_ANTI_ABUS.md new file mode 100644 index 0000000..841ccc0 --- /dev/null +++ b/docs/SECURITE_ANTI_ABUS.md @@ -0,0 +1,196 @@ +# Sécurité anti-abus — La Voix du Peuple + +> Document technique décrivant les protections contre les attaques sybil, les floods de bots et les brigading coordonnés. Mis à jour : mai 2026. + +--- + +## Contexte et risque + +La plateforme est conçue sans authentification (choix philosophique préservant l'anonymat). Cette absence rend triviale, sans protection, la manipulation des données par : + +- **Sybil attacks** : multiplication des soumissions depuis la même entité +- **Bot floods** : soumissions automatisées en masse +- **Brigading coordonné** : campagnes organisées pour inonder la synthèse de contenus orientés + +Les protections suivantes traitent ce risque sans remettre en cause l'anonymat des contributeurs légitimes. + +--- + +## Couches de protection (ordre d'application) + +### 1. Honeypot anti-bot + +**Principe** : un champ de formulaire est présent dans le HTML mais rendu invisible aux utilisateurs réels (`display: none`, `position: absolute`, `aria-hidden="true"`). Les bots qui analysent le DOM et remplissent tous les champs déclenchent le honeypot. + +**Comportement** : +- **Client** : si le champ `_hp` a une valeur lors de la soumission, l'appel API n'est pas effectué. Réponse simulée silencieuse côté JS. +- **Serveur** : si `_hp` est présent et non vide dans le corps JSON, le serveur retourne un `201` factice sans enregistrer quoi que ce soit (`logger.info("Honeypot déclenché")`). + +**Fichiers** : `artifacts/voix-du-peuple/src/pages/home.tsx` · `artifacts/flask-api/app.py` + +--- + +### 2. hCaptcha (stub — activer en production) + +**Principe** : widget CAPTCHA humain présenté avant la soumission. hCaptcha est choisi pour : +- Gratuit (tier communautaire) +- RGPD-compliant (pas de cookies tiers, données UE) +- Pas de dépendance à Google + +**État actuel** : stub intégré, désactivé par défaut. + +**Pour activer** : +1. Créer un compte sur [hcaptcha.com](https://www.hcaptcha.com/) +2. Créer un site, récupérer la clé de site et la clé secrète +3. Configurer les variables d'environnement : + +```env +# Frontend (.env dans artifacts/voix-du-peuple/) +VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site + +# Backend (.env ou variable système) +HCAPTCHA_SECRET_KEY=votre-cle-secrete +``` + +4. Reconstruire le frontend : `pnpm build` + +**Comportement quand activé** : +- Le widget hCaptcha s'affiche dans le formulaire avant le bouton "Contribuer" +- Le bouton est désactivé tant que le CAPTCHA n'est pas validé +- Le token est transmis dans l'en-tête `X-HCaptcha-Token` +- Le backend vérifie le token via l'API hCaptcha (`https://hcaptcha.com/siteverify`) +- Si la clé secrète n'est pas configurée côté serveur, la vérification est sautée (dégradation gracieuse) + +**Fichiers** : `artifacts/voix-du-peuple/src/pages/home.tsx` · `artifacts/flask-api/app.py` (`_verify_hcaptcha()`) + +--- + +### 3. Rate limiting par IP + fingerprint + +**Outil** : Flask-Limiter v3 avec stockage Redis (si `REDIS_URL` défini) ou mémoire (dev). + +**Seuils par défaut** (configurables via variables d'environnement) : + +| Endpoint | Limite par défaut | Variable de contrôle | +|----------|------------------|----------------------| +| `POST /api/ideas` | 5/min · **3/heure** | `RATE_LIMIT_CONTRIBUTIONS` | +| `POST /api/ideas/:id/flag` | 3/min · 10/heure | — | +| `POST /api/admin/login` | 10/min | — | +| Toutes routes | 60/heure · 200/jour | — | + +**Clé de rate limiting** : priorité au fingerprint FingerprintJS (hashé), sinon IP. Empêche de contourner la limite en changeant d'IP si le fingerprint est reconnu. + +**Pour activer Redis** : +```env +REDIS_URL=redis://localhost:6379/0 +``` + +Sans Redis, le rate limiting est en mémoire (reset au redémarrage — suffisant pour une instance unique). + +**Fichiers** : `artifacts/flask-api/app.py` (`get_fingerprint_key()`, `RATE_LIMIT_CONTRIBUTIONS`) + +--- + +### 4. Fingerprinting non-PII + +**Outil** : `@fingerprintjs/fingerprintjs` v4 (open source, pas de compte requis). + +**Principe** : FingerprintJS génère un `visitorId` côté client à partir de caractéristiques du navigateur (User-Agent, timezone, canvas fingerprint, etc.) sans créer de cookie tiers ni stocker de données personnelles. + +**Flux** : +1. À l'initialisation de l'app React (`App.tsx`), FingerprintJS est chargé +2. Le `visitorId` est stocké en mémoire (non persisté) +3. Il est envoyé sur chaque requête API dans l'en-tête `X-Visitor-Id` +4. Le backend le hash en SHA-256 (32 premiers hex) avant tout stockage +5. Le hash est enregistré en base (`ideas.fingerprint_hash`) pour analyse post-hoc si nécessaire + +**Données stockées** : uniquement le hash SHA-256 tronqué — non-réversible, non-PII au sens du RGPD. Aucun cookie, aucun suivi cross-site. + +**Fichiers** : `artifacts/voix-du-peuple/src/App.tsx` · `lib/api-client-react/src/custom-fetch.ts` · `artifacts/flask-api/app.py` · `artifacts/flask-api/database.py` + +--- + +### 5. Cooldown par session (cookie httpOnly signé) + +**Principe** : après une soumission acceptée, un cookie httpOnly signé HMAC-SHA256 est posé. Toute tentative de soumission avant l'expiration du cooldown est rejetée avec un `429`. + +**Durée par défaut** : 3600 secondes (1 heure), configurable : +```env +CONTRIBUTION_COOLDOWN_SECONDS=3600 +``` + +**Signature** : le cookie `_cv` contient `{timestamp}.{signature}` où la signature est `HMAC-SHA256(SECRET_KEY, timestamp_bytes)[:16]`. Impossible de forger sans connaître `SECRET_KEY`. + +**Prérequis** : +```env +SECRET_KEY=une-longue-chaine-aleatoire-minimum-32-chars +``` + +Si `SECRET_KEY` n'est pas défini, le cooldown est désactivé (dégradation gracieuse). + +**Limite** : fonctionne pleinement en production (même domaine, Nginx reverse proxy). En développement cross-origin (Vite sur port différent de Flask), le cookie n'est pas envoyé automatiquement par le navigateur (CORS `supports_credentials=False`). + +**Fichiers** : `artifacts/flask-api/app.py` (`_sign_cooldown()`, `_verify_cooldown()`) + +--- + +### 6. Détection de flood + +**Principe** : compteur en mémoire par IP (et par fingerprint si disponible) sur une fenêtre glissante de 5 minutes. Si le seuil est dépassé, une alerte `WARNING` est émise dans les logs. + +**Seuil par défaut** : 10 soumissions en 5 minutes, configurable : +```env +FLOOD_THRESHOLD=10 +``` + +**Ce qui se passe** : l'alerte est loggée mais la soumission n'est pas bloquée (le rate limiter Flask-Limiter s'en charge). L'objectif est d'alerter l'opérateur pour investigation. + +**Format de l'alerte** : +``` +WARNING ALERTE FLOOD — IP: 1.2.3.4 | fingerprint: abc123... | seuil: 10/5min +``` + +**Pour aller plus loin** : brancher sur un webhook (email Mailgun/Brevo, Slack/Mattermost) via un hook sur les logs `WARNING` avec le pattern `ALERTE FLOOD`. + +**Limite** : l'état est en mémoire et se réinitialise au redémarrage. Pour une persistance cross-restart, utiliser Redis directement avec `EXPIRE`. + +**Fichiers** : `artifacts/flask-api/app.py` (`_check_flood()`, `_flood_tracker`) + +--- + +## Variables d'environnement récapitulatif + +```env +# Rate limiting +REDIS_URL=redis://localhost:6379/0 # Optionnel — sinon mémoire +RATE_LIMIT_CONTRIBUTIONS=5 per minute;3 per hour # Format flask-limiter + +# Cooldown session +SECRET_KEY=une-longue-chaine-aleatoire-minimum-32-chars +CONTRIBUTION_COOLDOWN_SECONDS=3600 + +# Flood detection +FLOOD_THRESHOLD=10 + +# hCaptcha (désactivé si absent) +HCAPTCHA_SECRET_KEY=votre-cle-secrete # Backend +VITE_HCAPTCHA_SITE_KEY=votre-cle-de-site # Frontend (nécessite rebuild) +``` + +--- + +## Ce que ces protections ne couvrent pas + +- **Bots sophistiqués JavaScript-capable** : FingerprintJS peut être contourné par un navigateur headless bien configuré. La combinaison IP + fingerprint + hCaptcha rend l'attaque coûteuse mais pas impossible. +- **VPN / Tor** : le rate limiting IP peut être contourné. Le fingerprint compense partiellement. +- **Submissions manuelles coordonnées** (brigading humain) : seul le contenu + la modération IA protège contre ce vecteur. +- **Cross-origin en dev** : le cookie cooldown ne fonctionne pas en développement (ports différents, CORS sans credentials). + +--- + +## Évolutions futures recommandées + +1. **Redis** : déployer Redis et configurer `REDIS_URL` en production pour un rate limiting persistant et cross-process. +2. **hCaptcha** : activer dès que la plateforme est ouverte au public (clé gratuite, 5 minutes de setup). +3. **Alerte flood automatique** : brancher un webhook Brevo/Mailgun sur les logs `ALERTE FLOOD`. +4. **CAPTCHA invisible** : envisager hCaptcha en mode "invisible" (score-based) pour ne pas imposer de défi aux utilisateurs légitimes. diff --git a/lib/api-client-react/src/custom-fetch.ts b/lib/api-client-react/src/custom-fetch.ts index 3a5021b..de5b30c 100644 --- a/lib/api-client-react/src/custom-fetch.ts +++ b/lib/api-client-react/src/custom-fetch.ts @@ -18,6 +18,35 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json"; let _baseUrl: string | null = null; let _authTokenGetter: AuthTokenGetter | null = null; +// Identifiant de visite non-PII issu de FingerprintJS (hash côté serveur) +let _visitorId: string | null = null; + +// En-têtes supplémentaires par requête (ex. token hCaptcha) +const _extraHeaders: Record = {}; + +/** + * Enregistre l'identifiant de visite FingerprintJS. + * Envoyé automatiquement comme en-tête X-Visitor-Id sur chaque requête. + */ +export function setVisitorId(id: string): void { + _visitorId = id; +} + +/** + * Ajoute ou met à jour un en-tête supplémentaire pour les prochaines requêtes. + * Utiliser pour passer des tokens à usage unique (ex. hCaptcha). + */ +export function addExtraHeader(key: string, value: string): void { + _extraHeaders[key] = value; +} + +/** + * Supprime un en-tête supplémentaire. + */ +export function removeExtraHeader(key: string): void { + delete _extraHeaders[key]; +} + /** * Set a base URL that is prepended to every relative request URL * (i.e. paths that start with `/`). @@ -358,6 +387,18 @@ export async function customFetch( } } + // En-tête fingerprint non-PII (FingerprintJS) + if (_visitorId && !headers.has("x-visitor-id")) { + headers.set("x-visitor-id", _visitorId); + } + + // En-têtes supplémentaires (ex. token hCaptcha) + for (const [key, value] of Object.entries(_extraHeaders)) { + if (value && !headers.has(key)) { + headers.set(key, value); + } + } + const requestInfo = { method, url: resolveUrl(input) }; const response = await fetch(input, { ...init, method, headers }); diff --git a/lib/api-client-react/src/index.ts b/lib/api-client-react/src/index.ts index 980b8e2..2499789 100644 --- a/lib/api-client-react/src/index.ts +++ b/lib/api-client-react/src/index.ts @@ -1,4 +1,10 @@ export * from "./generated/api"; export * from "./generated/api.schemas"; -export { setBaseUrl, setAuthTokenGetter } from "./custom-fetch"; +export { + setBaseUrl, + setAuthTokenGetter, + setVisitorId, + addExtraHeader, + removeExtraHeader, +} from "./custom-fetch"; export type { AuthTokenGetter } from "./custom-fetch";