# Protection anti-spam des formulaires WININFO

## Inventaire (8 septembre 2026)

Recensement du code et lectures SQL sur la base effective `site_wininfo` : **28 formulaires publiés**. Tous soumettent à `POST /forms/submit` et passent par `FrontController::submitForm()`.

| Famille | Nombre publié | Composant |
| --- | ---: | --- |
| Contact (`contact`) | 1 | `views/front/partials/form_block.php` |
| Démonstration (`demande-de-demonstration`) | 1 | Même composant |
| Inscription aux formations | 25 | Même composant via `sections.php` |
| Campagne `win-erp-nouvelle-generation` | 1 | `views/front/partials/post_campaign_response.php` |
| Blocs de formulaire des fiches produits/modules | 0 actuellement dans `entity_sections` | Déjà couverts via `product_sections.php` et `form_block.php` |

Les 25 slugs de formation :

```text
win-atelier-formation
win-btp-avance-formation
win-btp-formation
win-erp-achats-formation
win-erp-formation
win-erp-tronc-commun
win-erp-ventes-formation
win-logistique-fw03
win-messagerie-formation
win-tablette
win-tp-fw04
win-transport-espace-client
win-transport-exploitation
win-transport-express
win-transport-facturation
win-transport-logistique
win-transport-tp
win-wms-entrepot
win-wms-generalites
win-wms-inventaire
win-wms-mouvements-stock
win-wms-supervision-expeditions
win-wms-supervision-receptions
win-wms-traitement-commandes
win-wms-traitement-receptions
```

Les recherches sur `/recherche` et la page 404 utilisent GET et n'enregistrent pas de demandes : pas de challenge. Le suivi d'engagement (`/page-views/{id}/engagement`) n'est pas un formulaire de demande. Les formulaires du back-office, dont sa connexion, restent hors du périmètre validé.

## Fonctionnement

Le serveur vérifie successivement la configuration, le quota IP, les types des champs, le champ piège `website_url`, le CSRF, les champs métier puis le jeton Turnstile. Il enregistre la demande et lance sa notification uniquement si ces contrôles réussissent. Même un POST direct sans JavaScript doit passer ces contrôles.

La validation appelle exclusivement `https://challenges.cloudflare.com/turnstile/v0/siteverify`, avec vérification TLS, délai de 8 secondes, réponse de taille limitée et sans suivre les redirections. Elle exige `success === true`, l'action fixe `public_form` et un domaine figurant dans la liste configurée. Cloudflare assure l'expiration et l'usage unique du jeton ; aucune validation n'est mise en cache. Voir la [validation serveur officielle](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/).

Le script charge Cloudflare seulement lorsqu'un formulaire est présent, gère plusieurs widgets indépendants, les erreurs de chargement, l'expiration, les nouvelles tentatives et le retour arrière du navigateur. Le champ piège est exclu du parcours clavier et des lecteurs d'écran. Les champs métier sont conservés en session pendant au plus 15 minutes après un refus, pour le formulaire et la page concernés ; ils sont échappés au réaffichage. Les clés secrètes et jetons ne sont jamais conservés dans ces données de retour.

Le quota est une fenêtre glissante : **5 tentatives sur 900 secondes par IP**, tous formulaires et sessions confondus. Les tentatives refusées par le champ piège, le CSRF ou Turnstile comptent aussi. Après épuisement, les nouvelles tentatives sont refusées sans prolonger le délai et sans appel Cloudflare. Les IP sont normalisées puis hachées avec HMAC ; aucune IP en clair n'est stockée dans les compteurs.

Un seul fichier privé `attempts.json` contient les compteurs. `flock` protège leur lecture/modification entre processus PHP. Les entrées expirées sont éliminées lors des mises à jour ; le stockage est borné à 10 000 IP actives et 2 Mo. Une capacité atteinte, une corruption, un stockage inaccessible, une configuration incomplète ou une panne de Cloudflare **bloque les envois** avec un message explicite. Les pannes techniques écrivent un diagnostic générique dans le journal PHP, sans données du formulaire ni secrets.

## Configuration centralisée

Valeurs par défaut dans `config/antispam.php`, intégrées à `config/app.php`. Copier `config/antispam.local.php.example` vers `config/antispam.local.php` sur chaque environnement, ou utiliser les variables suivantes. Le fichier local surcharge les variables ; une éventuelle surcharge `antispam` dans `config/app.local.php` intervient ensuite.

| Variable | Clé PHP | Valeur / rôle |
| --- | --- | --- |
| `WININFO_TURNSTILE_SITE_KEY` | `site_key` | Clé publique du widget |
| `WININFO_TURNSTILE_SECRET_KEY` | `secret_key` | Clé secrète, uniquement côté serveur |
| `WININFO_TURNSTILE_HOSTNAMES` | `hostnames` | Domaines exacts séparés par virgules, sans schéma, chemin, port ou joker |
| `WININFO_ANTISPAM_MAX_ATTEMPTS` | `max_attempts` | 5 par défaut ; entre 1 et 100 |
| `WININFO_ANTISPAM_WINDOW_SECONDS` | `window_seconds` | 900 par défaut ; entre 1 et 86400 |
| `WININFO_ANTISPAM_STORAGE_PATH` | `storage_path` | Chemin privé absolu ; par défaut `storage/antispam` dans le projet |
| — | `timeout_seconds` | 8 par défaut, borné entre 1 et 15 secondes |

Utiliser des clés distinctes pour développement et production. Le fichier local et les compteurs sont ignorés par Git et exclus des transferts CI, ainsi que les tests. Une rotation de la clé secrète change les identifiants HMAC et renouvelle donc les quotas existants.

## Mise en production

1. Créer un widget Turnstile dans Cloudflare, en mode Managed, avec les domaines réels du site (inclure séparément le domaine avec et sans `www` s'ils sont servis). Cette création fournit les deux clés ; aucune clé réelle n'est incluse dans ce changement.
2. **Avant de déployer le code**, créer `config/antispam.local.php` sur le serveur avec les clés et les mêmes domaines. Le code bloque les soumissions tant que cette configuration manque.
3. Préparer le répertoire des compteurs hors de `public/`, persistant entre déploiements, accessible en lecture/écriture à l'utilisateur PHP. Le document root doit rester `public/`. Ne pas partager ces compteurs entre dev et prod ; ne pas les effacer à chaque déploiement.
4. Vérifier PHP 8.1+ (8.2 utilisé pour les tests), OpenSSL, les certificats CA, `allow_url_fopen` et la sortie HTTPS vers `challenges.cloudflare.com:443`. Si une CSP est présente, autoriser Cloudflare dans `script-src` et `frame-src` conformément à la [documentation CSP](https://developers.cloudflare.com/turnstile/reference/content-security-policy/).
5. Si le site est derrière un proxy, configurer la restitution de l'IP réelle au niveau du serveur HTTP (par exemple `mod_remoteip` avec uniquement les proxies de confiance). L'application lit **uniquement `REMOTE_ADDR`** et ignore `X-Forwarded-For` et `CF-Connecting-IP`. Sans cette configuration, tous les visiteurs passant par le même proxy partageront un quota. Ne jamais faire confiance à ces en-têtes depuis n'importe quelle source.
6. Déployer ensemble PHP, templates et assets par le circuit habituel. Les jobs CI dev/prod préservent le fichier secret et le stockage. Aucune migration SQL ni nettoyage des soumissions existantes n'est nécessaire.
7. Exécuter `php bin/antispam_check.php` avec le même utilisateur et la même configuration que PHP côté web. Ce contrôle vérifie clés non factices, domaines, capacités HTTPS, chemin privé, écriture et verrouillage. Il ne vérifie pas la validité des clés chez Cloudflare ni leur association au domaine et ne réinitialise aucun quota.
8. Sur le domaine cible, valider un envoi de contact, une inscription et une réponse de campagne : un enregistrement et une notification par envoi. Vérifier aussi une page à plusieurs formulaires si présente. Utiliser une adresse de test maîtrisée ; ces essais métier produisent de vraies demandes.
9. Vérifier le refus d'un jeton manquant/expiré, d'un champ piège rempli et de la sixième tentative sur une même IP. Vérifier une seconde IP, le renouvellement après 15 minutes, le retour arrière, la conservation des champs et le message si le script Cloudflare est bloqué.

Pour plusieurs serveurs applicatifs, tous les processus doivent utiliser **le même stockage avec un verrouillage fiable**. Des disques locaux séparés multiplient le quota par le nombre de serveurs : adapter le stockage à un service partagé avant ce type de déploiement. Le quota par IP implique aussi un quota partagé pour les visiteurs d'un même réseau d'entreprise ; ajuster le seuil d'après les retours réels. Cette protection ne remplace pas une limitation du trafic HTTP au niveau du serveur contre un déni de service.

En cas de problème de clés, domaine ou permissions, corriger la configuration puis refaire le contrôle. En cas de rollback applicatif, restaurer ensemble les anciens PHP et assets, et conserver le fichier secret et le stockage. Le rollback retire la protection des formulaires : maintenir une restriction d'accès temporaire si nécessaire pendant la correction.

## Tests reproductibles

```text
php tests/antispam.php
node tests/antispam-js.cjs
php tests/turnstile_transport.php
php bin/antispam_check.php
```

Les tests PHP principaux utilisent des répertoires temporaires, un client Cloudflare simulé, un dépôt mémoire et un collecteur de notifications. Ils ne se connectent pas à MySQL et n'envoient pas d'email. Ils couvrent également le vrai contrôleur, les deux templates et douze processus concurrents. Les tests JavaScript simulent le DOM et l'API du widget ; ils ne remplacent pas la recette navigateur sur le domaine réel.

Le test de transport est optionnel et nécessite Internet. Il utilise uniquement les [clés publiques de test Cloudflare](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) pour vérifier succès, refus et jeton déjà utilisé. Il ne teste pas les clés de production ni le contrôle d'action/domaine de bout en bout. Ne pas copier ces clés dans la configuration de production.

## Fichiers concernés et réutilisation

| Fichiers | Rôle |
| --- | --- |
| `app/Support/PublicFormProtection.php` | Contrôle commun, CSRF, champ piège, orchestration et messages |
| `app/Support/IpRateLimiter.php` | Quota par IP et stockage verrouillé |
| `app/Support/TurnstileClient.php` | Appel HTTPS à Siteverify |
| `app/Support/helpers.php` | Création du service, retour d'erreur et champs conservés, chargement unique du script |
| `app/Controller/FrontController.php` | Contrôle avant enregistrement et notification |
| `views/front/partials/antispam.php` | Composant réutilisable du widget et du champ piège |
| `views/front/partials/form_block.php`, `post_campaign_response.php` | Intégration et restauration des champs |
| `public/assets/antispam.js`, `public/assets/app.css` | Comportement du widget et masquage accessible du champ piège |
| `config/app.php`, `config/antispam.php`, `config/antispam.local.php.example` | Configuration |
| `bin/antispam_check.php` | Contrôle avant production |
| `tests/antispam.php`, `tests/antispam-js.cjs`, `tests/turnstile_transport.php` | Tests sans données métier |
| `.gitignore`, `.gitlab-ci.yml` | Préservation des secrets et compteurs |
| `README.md`, ce guide | Documentation |

Pour ajouter un formulaire de demande, privilégier le bloc administrable `form-block` : il est automatiquement protégé. Pour un nouveau template, reprendre les champs/contextes existants, appeler `public_form_feedback($formKey, $returnUrl)`, inclure `partials/antispam.php` à l'intérieur du formulaire et soumettre à `/forms/submit`. Un nouvel endpoint doit appeler `public_form_protection()->check(...)` avant tout effet métier. Le service actuel exige les champs communs nom/email/message ; adapter explicitement la validation métier si un futur formulaire utilise un autre contrat.
