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:
@@ -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.
|
||||||
@@ -94,10 +94,28 @@ Le site écoute sur le **port HTTP 8080**. Vous gérez le HTTPS en amont.
|
|||||||
## Variables d'environnement clés
|
## Variables d'environnement clés
|
||||||
|
|
||||||
```env
|
```env
|
||||||
|
# Base de données
|
||||||
DATABASE_URL=postgresql://user:pass@localhost:5432/voix_du_peuple
|
DATABASE_URL=postgresql://user:pass@localhost:5432/voix_du_peuple
|
||||||
|
|
||||||
|
# IA (Mistral recommandé — souveraineté européenne)
|
||||||
MISTRAL_API_KEY=sk-...
|
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_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/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/INSTALL_ROCKY.md`](docs/INSTALL_ROCKY.md) | Installation sur RockyLinux 9 |
|
||||||
| [`docs/GITEA_TUTO.md`](docs/GITEA_TUTO.md) | Synchronisation Replit → Gitea |
|
| [`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
|
## 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.
|
||||||
|
|||||||
@@ -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.
|
Agent IA pour le filtrage éthique et la synthèse démocratique.
|
||||||
Supporte Mistral AI, OpenAI, et les intégrations Replit AI.
|
Supporte Mistral AI, OpenAI, et les intégrations Replit AI.
|
||||||
"""
|
"""
|
||||||
|
|||||||
+205
-23
@@ -1,29 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
La Voix du Peuple — Backend Flask
|
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.
|
Plateforme démocratique citoyenne.
|
||||||
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
|
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.
|
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
|
||||||
|
|
||||||
Sécurité :
|
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)
|
- Validation et assainissement des entrées (bleach)
|
||||||
- CORS restreint
|
- CORS restreint
|
||||||
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
|
- En-têtes de sécurité HTTP
|
||||||
- Protection contre l'injection via requêtes paramétrées (psycopg2)
|
|
||||||
- Panel admin protégé par ADMIN_SECRET (Bearer token)
|
- Panel admin protégé par ADMIN_SECRET (Bearer token)
|
||||||
- Aucun secret exposé dans les réponses d'erreur
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import io
|
import io
|
||||||
import os
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import bleach
|
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_cors import CORS
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
@@ -43,27 +55,50 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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 ────────────────────────────────────────────────────────────
|
# ─── Application ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["JSON_SORT_KEYS"] = False
|
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)
|
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(
|
limiter = Limiter(
|
||||||
get_remote_address,
|
get_remote_address,
|
||||||
app=app,
|
app=app,
|
||||||
default_limits=["200 per day", "60 per hour"],
|
default_limits=["200 per day", "60 per hour"],
|
||||||
storage_uri="memory://",
|
storage_uri=_storage_uri,
|
||||||
strategy="fixed-window",
|
strategy="fixed-window",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── En-têtes de sécurité HTTP ───────────────────────────────────────────────
|
# ─── En-têtes de sécurité HTTP ───────────────────────────────────────────────
|
||||||
|
|
||||||
@app.after_request
|
@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-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
response.headers["X-Frame-Options"] = "DENY"
|
||||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
@@ -83,10 +118,12 @@ CONTENT_MIN = 10
|
|||||||
CONTENT_MAX = 1000
|
CONTENT_MAX = 1000
|
||||||
AUTHOR_MAX = 100
|
AUTHOR_MAX = 100
|
||||||
|
|
||||||
|
|
||||||
def sanitize_text(text: str) -> str:
|
def sanitize_text(text: str) -> str:
|
||||||
"""Supprime tout HTML/JavaScript — protection XSS."""
|
"""Supprime tout HTML/JavaScript — protection XSS."""
|
||||||
return bleach.clean(text, tags=[], strip=True).strip()
|
return bleach.clean(text, tags=[], strip=True).strip()
|
||||||
|
|
||||||
|
|
||||||
def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
|
def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
|
||||||
"""Valide et assainit les données de soumission d'une idée."""
|
"""Valide et assainit les données de soumission d'une idée."""
|
||||||
content = data.get("content")
|
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:
|
def _get_admin_secret() -> str | None:
|
||||||
return os.environ.get("ADMIN_SECRET")
|
return os.environ.get("ADMIN_SECRET")
|
||||||
|
|
||||||
|
|
||||||
def require_admin(f):
|
def require_admin(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
@@ -131,20 +169,95 @@ def require_admin(f):
|
|||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated
|
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 ─────────────────────────────────────────────────────
|
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.errorhandler(400)
|
@app.errorhandler(400)
|
||||||
def bad_request(e):
|
def bad_request(e):
|
||||||
return jsonify({"error": "bad_request", "message": "Requête invalide."}), 400
|
return jsonify({"error": "bad_request", "message": "Requête invalide."}), 400
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
def not_found(e):
|
def not_found(e):
|
||||||
return jsonify({"error": "not_found", "message": "Ressource introuvable."}), 404
|
return jsonify({"error": "not_found", "message": "Ressource introuvable."}), 404
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(405)
|
@app.errorhandler(405)
|
||||||
def method_not_allowed(e):
|
def method_not_allowed(e):
|
||||||
return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée."}), 405
|
return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée."}), 405
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(429)
|
@app.errorhandler(429)
|
||||||
def rate_limit_exceeded(e):
|
def rate_limit_exceeded(e):
|
||||||
return jsonify({
|
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.",
|
"message": "Trop de requêtes. Veuillez patienter avant de soumettre une nouvelle idée.",
|
||||||
}), 429
|
}), 429
|
||||||
|
|
||||||
|
|
||||||
@app.errorhandler(500)
|
@app.errorhandler(500)
|
||||||
def internal_error(e):
|
def internal_error(e):
|
||||||
logger.exception("Erreur interne non gérée")
|
logger.exception("Erreur interne non gérée")
|
||||||
return jsonify({"error": "internal_error", "message": "Erreur interne du serveur."}), 500
|
return jsonify({"error": "internal_error", "message": "Erreur interne du serveur."}), 500
|
||||||
|
|
||||||
# ─── Routes ──────────────────────────────────────────────────────────────────
|
# ─── Routes publiques ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/healthz")
|
@app.get("/api/healthz")
|
||||||
def health():
|
def health():
|
||||||
@@ -175,18 +289,25 @@ def list_ideas():
|
|||||||
@app.get("/api/ideas/stats")
|
@app.get("/api/ideas/stats")
|
||||||
@limiter.limit("120 per minute")
|
@limiter.limit("120 per minute")
|
||||||
def idea_stats():
|
def idea_stats():
|
||||||
"""Statistiques : total, acceptées, rejetées."""
|
"""Statistiques publiques : total, acceptées, rejetées."""
|
||||||
stats = get_stats()
|
stats = get_stats()
|
||||||
return jsonify(stats)
|
return jsonify(stats)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/ideas")
|
@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():
|
def submit_idea():
|
||||||
"""
|
"""
|
||||||
Soumet une idée citoyenne.
|
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:
|
if not request.is_json:
|
||||||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||||||
@@ -195,6 +316,33 @@ def submit_idea():
|
|||||||
if data is None:
|
if data is None:
|
||||||
return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400
|
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)
|
validated, error = validate_idea_input(data)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({"error": "validation_error", "message": error}), 400
|
return jsonify({"error": "validation_error", "message": error}), 400
|
||||||
@@ -202,26 +350,54 @@ def submit_idea():
|
|||||||
content = validated["content"]
|
content = validated["content"]
|
||||||
author = validated["author"]
|
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)
|
filter_result = filter_idea(content)
|
||||||
accepted = bool(filter_result.get("accepted", False))
|
accepted = bool(filter_result.get("accepted", False))
|
||||||
rejection_reason = filter_result.get("reason") if not accepted else None
|
rejection_reason = filter_result.get("reason") if not accepted else None
|
||||||
legal_basis = filter_result.get("legal_basis") 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:
|
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()
|
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||||
|
|
||||||
return jsonify({
|
# Construction de la réponse
|
||||||
|
resp_data = {
|
||||||
"id": idea["id"],
|
"id": idea["id"],
|
||||||
"accepted": accepted,
|
"accepted": accepted,
|
||||||
"reason": rejection_reason,
|
"reason": rejection_reason,
|
||||||
"legalBasis": legal_basis if not accepted else None,
|
"legalBasis": legal_basis if not accepted else None,
|
||||||
"idea": serialize_idea(idea),
|
"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")
|
@app.get("/api/synthesis")
|
||||||
@@ -245,6 +421,7 @@ def get_synthesis_route():
|
|||||||
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ─── Route publique : signalement ────────────────────────────────────────────
|
# ─── Route publique : signalement ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.post("/api/ideas/<int:idea_id>/flag")
|
@app.post("/api/ideas/<int:idea_id>/flag")
|
||||||
@@ -378,7 +555,7 @@ def admin_export_csv():
|
|||||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||||
writer.writerow(["id", "content", "author", "accepted", "flagged",
|
writer.writerow(["id", "content", "author", "accepted", "flagged",
|
||||||
"flag_count", "rejection_reason", "legal_basis",
|
"flag_count", "rejection_reason", "legal_basis",
|
||||||
"admin_note", "created_at"])
|
"admin_note", "fingerprint_hash", "created_at"])
|
||||||
for idea in ideas:
|
for idea in ideas:
|
||||||
writer.writerow([
|
writer.writerow([
|
||||||
idea.get("id"),
|
idea.get("id"),
|
||||||
@@ -390,6 +567,7 @@ def admin_export_csv():
|
|||||||
idea.get("rejection_reason", ""),
|
idea.get("rejection_reason", ""),
|
||||||
idea.get("legal_basis", ""),
|
idea.get("legal_basis", ""),
|
||||||
idea.get("admin_note", ""),
|
idea.get("admin_note", ""),
|
||||||
|
idea.get("fingerprint_hash", ""),
|
||||||
idea.get("created_at").isoformat() if idea.get("created_at") else "",
|
idea.get("created_at").isoformat() if idea.get("created_at") else "",
|
||||||
])
|
])
|
||||||
csv_bytes = output.getvalue().encode("utf-8-sig")
|
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:
|
def serialize_idea_admin(idea: dict) -> dict:
|
||||||
base = serialize_idea(idea)
|
base = serialize_idea(idea)
|
||||||
base["adminNote"] = idea.get("admin_note")
|
base["adminNote"] = idea.get("admin_note")
|
||||||
|
base["fingerprintHash"] = idea.get("fingerprint_hash")
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def _update_synthesis_background():
|
def _update_synthesis_background() -> None:
|
||||||
try:
|
try:
|
||||||
ideas = get_accepted_ideas()
|
ideas = get_accepted_ideas()
|
||||||
texts = [i["content"] for i in ideas]
|
texts = [i["content"] for i in ideas]
|
||||||
@@ -437,5 +616,8 @@ if __name__ == "__main__":
|
|||||||
port = int(os.environ.get("PORT", 8080))
|
port = int(os.environ.get("PORT", 8080))
|
||||||
logger.info("Initialisation de la base de données...")
|
logger.info("Initialisation de la base de données...")
|
||||||
init_db()
|
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)
|
app.run(host="0.0.0.0", port=port, debug=False)
|
||||||
|
|||||||
@@ -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.
|
Utilise psycopg2 directement — pas d'ORM, code lisible et transparent.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
@@ -32,7 +34,7 @@ def db_cursor():
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db() -> None:
|
||||||
"""Crée les tables si elles n'existent pas, et applique les migrations nécessaires."""
|
"""Crée les tables si elles n'existent pas, et applique les migrations nécessaires."""
|
||||||
with db_cursor() as cur:
|
with db_cursor() as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -46,10 +48,12 @@ def init_db():
|
|||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
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 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 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 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 admin_note TEXT")
|
||||||
|
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS fingerprint_hash VARCHAR(64)")
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS synthesis (
|
CREATE TABLE IF NOT EXISTS synthesis (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -61,16 +65,22 @@ def init_db():
|
|||||||
logger.info("Base de données initialisée.")
|
logger.info("Base de données initialisée.")
|
||||||
|
|
||||||
|
|
||||||
def insert_idea(content: str, author: str | None, accepted: bool,
|
def insert_idea(
|
||||||
rejection_reason: str | None, legal_basis: str | None) -> dict:
|
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:
|
with db_cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis)
|
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""",
|
""",
|
||||||
(content, author, accepted, rejection_reason, legal_basis),
|
(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash),
|
||||||
)
|
)
|
||||||
return dict(cur.fetchone())
|
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()]
|
return [dict(row) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def get_ideas_admin(status: str = "all", page: int = 1,
|
def get_ideas_admin(
|
||||||
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
|
status: str = "all",
|
||||||
|
page: int = 1,
|
||||||
|
per_page: int = 50,
|
||||||
|
search: str = "",
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
conditions = []
|
conditions = []
|
||||||
params: list = []
|
params: list = []
|
||||||
@@ -138,8 +152,12 @@ def bulk_delete_ideas(idea_ids: list[int]) -> int:
|
|||||||
return len(cur.fetchall())
|
return len(cur.fetchall())
|
||||||
|
|
||||||
|
|
||||||
def override_idea(idea_id: int, accepted: bool,
|
def override_idea(
|
||||||
reason: str | None, note: str | None) -> dict | None:
|
idea_id: int,
|
||||||
|
accepted: bool,
|
||||||
|
reason: str | None,
|
||||||
|
note: str | None,
|
||||||
|
) -> dict | None:
|
||||||
with db_cursor() as cur:
|
with db_cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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.
|
Base légale internationale ET française servant de référence pour le filtre éthique.
|
||||||
|
|
||||||
Sources internationales :
|
Sources internationales :
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ gunicorn>=23.0.0
|
|||||||
openai>=1.77.0
|
openai>=1.77.0
|
||||||
psycopg2-binary>=2.9.10
|
psycopg2-binary>=2.9.10
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
|
redis>=5.0.0
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||||
|
"@hcaptcha/react-hcaptcha": "^1.11.0",
|
||||||
"qrcode.react": "^4.2.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 { Switch, Route, Router as WouterRouter, Link } from "wouter";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
@@ -10,6 +12,8 @@ import Flyer from "@/pages/flyer";
|
|||||||
import Admin from "@/pages/admin";
|
import Admin from "@/pages/admin";
|
||||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||||
|
import { setVisitorId } from "@workspace/api-client-react";
|
||||||
|
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -77,6 +81,18 @@ function Router() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -5,6 +6,7 @@ import { z } from "zod";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
import {
|
import {
|
||||||
useSubmitIdea,
|
useSubmitIdea,
|
||||||
useListIdeas,
|
useListIdeas,
|
||||||
@@ -12,6 +14,8 @@ import {
|
|||||||
useGetSynthesis,
|
useGetSynthesis,
|
||||||
getListIdeasQueryKey,
|
getListIdeasQueryKey,
|
||||||
getGetIdeaStatsQueryKey,
|
getGetIdeaStatsQueryKey,
|
||||||
|
addExtraHeader,
|
||||||
|
removeExtraHeader,
|
||||||
} from "@workspace/api-client-react";
|
} from "@workspace/api-client-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
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 ?? "";
|
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -84,6 +91,13 @@ export default function Home() {
|
|||||||
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
|
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
|
||||||
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
|
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) => {
|
const handleFlag = async (ideaId: number) => {
|
||||||
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
||||||
setFlaggingId(ideaId);
|
setFlaggingId(ideaId);
|
||||||
@@ -173,6 +187,29 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: SubmitIdeaValues) => {
|
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);
|
setSubmitResult(null);
|
||||||
submitIdea.mutate({ data }, {
|
submitIdea.mutate({ data }, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -198,6 +235,12 @@ export default function Home() {
|
|||||||
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
|
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 {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
@@ -268,7 +322,7 @@ export default function Home() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="font-bold tracking-wide flex-shrink-0"
|
className="font-bold tracking-wide flex-shrink-0"
|
||||||
disabled={submitIdea.isPending}
|
disabled={submitIdea.isPending || (HCAPTCHA_SITE_KEY ? !captchaToken : false)}
|
||||||
data-testid="button-submit-idea"
|
data-testid="button-submit-idea"
|
||||||
>
|
>
|
||||||
{submitIdea.isPending ? (
|
{submitIdea.isPending ? (
|
||||||
@@ -278,6 +332,21 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -18,6 +18,35 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
|
|||||||
let _baseUrl: string | null = null;
|
let _baseUrl: string | null = null;
|
||||||
let _authTokenGetter: AuthTokenGetter | 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<string, string> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Set a base URL that is prepended to every relative request URL
|
||||||
* (i.e. paths that start with `/`).
|
* (i.e. paths that start with `/`).
|
||||||
@@ -358,6 +387,18 @@ export async function customFetch<T = unknown>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 requestInfo = { method, url: resolveUrl(input) };
|
||||||
|
|
||||||
const response = await fetch(input, { ...init, method, headers });
|
const response = await fetch(input, { ...init, method, headers });
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
export * from "./generated/api";
|
export * from "./generated/api";
|
||||||
export * from "./generated/api.schemas";
|
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";
|
export type { AuthTokenGetter } from "./custom-fetch";
|
||||||
|
|||||||
Reference in New Issue
Block a user