Anti-spam: add Cloudflare Turnstile (CAPTCHA) on registration form

- contact.html: ajout du widget Turnstile (site key: 0x4AAAAAADKDuc7Rmlb1svIL)
- form-handler.js: blocage de la soumission si pas de token Turnstile valide
- Worker: validation server-side du token via /turnstile/v0/siteverify
  avant chaque appel sendWelcomeNow → bloque les bots qui n'auraient
  pas passé le challenge côté client.

Le secret Turnstile est en env var Cloudflare (TURNSTILE_SECRET).

Limite humain : Turnstile détecte les bots avec très peu d'interaction
côté utilisateur (mode 'Managed', le plus souvent invisible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MVA Global Fret 2026-05-06 10:47:29 +02:00
parent 313c870ea4
commit a3a36df811
3 changed files with 66 additions and 2 deletions

View File

@ -107,8 +107,16 @@ export default {
// Paris depuis env var). Appelé par form-handler.js après // Paris depuis env var). Appelé par form-handler.js après
// soumission du formulaire. L'adresse n'apparaît jamais dans // soumission du formulaire. L'adresse n'apparaît jamais dans
// le code JS public — elle vient des secrets Cloudflare. // le code JS public — elle vient des secrets Cloudflare.
// Anti-bot : on vérifie d'abord le token Cloudflare Turnstile.
if (action === 'sendWelcomeNow') { if (action === 'sendWelcomeNow') {
if (!body.email) return jsonResponse({ error: 'email requis' }, 400); if (!body.email) return jsonResponse({ error: 'email requis' }, 400);
// Validation Turnstile (anti-bot)
const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request);
if (!turnstileOk) {
return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403);
}
try { try {
await sendWelcomeEmail(env, { await sendWelcomeEmail(env, {
firstname : body.firstname || '', firstname : body.firstname || '',
@ -346,6 +354,39 @@ async function sendWelcomeEmail(env, params) {
} }
} }
// =============================================================
// Cloudflare Turnstile : validation anti-bot
// =============================================================
// Reçoit le token généré côté client (window.turnstileToken) et
// l'envoie à l'API Cloudflare avec le secret pour validation.
// Renvoie true uniquement si Cloudflare confirme que c'est un
// utilisateur humain.
async function verifyTurnstile(env, token, request) {
if (!token) return false;
if (!env.TURNSTILE_SECRET) {
// En dev / si pas configuré, on laisse passer (à durcir en prod)
console.warn('TURNSTILE_SECRET not set, skipping validation');
return true;
}
const ip = request.headers.get('CF-Connecting-IP') || '';
const formData = new FormData();
formData.append('secret', env.TURNSTILE_SECRET);
formData.append('response', token);
if (ip) formData.append('remoteip', ip);
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data.success === true;
} catch (err) {
console.warn('Turnstile verification error:', err);
return false;
}
}
// ============================================================= // =============================================================
// Helpers // Helpers
// ============================================================= // =============================================================

View File

@ -141,6 +141,9 @@
<div class="form-error" id="error-cgv"></div> <div class="form-error" id="error-cgv"></div>
</div> </div>
<!-- Cloudflare Turnstile (CAPTCHA invisible/léger pour bloquer les bots) -->
<div class="cf-turnstile" data-sitekey="0x4AAAAAADKDuc7Rmlb1svIL" data-callback="onTurnstileSuccess" data-error-callback="onTurnstileError" style="margin-bottom: 16px;"></div>
<div id="formErrorGlobal" style="display:none; color: var(--red); margin-bottom: 16px; font-size: 0.9rem;"></div> <div id="formErrorGlobal" style="display:none; color: var(--red); margin-bottom: 16px; font-size: 0.9rem;"></div>
<button type="submit" class="btn btn-primary" id="submitBtn" style="width: 100%; justify-content: center;"> <button type="submit" class="btn btn-primary" id="submitBtn" style="width: 100%; justify-content: center;">
@ -148,6 +151,13 @@
<span id="submitText" data-i18n="contact.submitBtn">S'inscrire</span> <span id="submitText" data-i18n="contact.submitBtn">S'inscrire</span>
</button> </button>
</form> </form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script>
window.turnstileToken = null;
window.onTurnstileSuccess = function(token) { window.turnstileToken = token; };
window.onTurnstileError = function() { window.turnstileToken = null; };
</script>
</div> </div>
<!-- COORDONNÉES --> <!-- COORDONNÉES -->

View File

@ -82,6 +82,17 @@ function setupContactForm(form) {
e.preventDefault(); e.preventDefault();
if (!validateForm(form)) return; if (!validateForm(form)) return;
// ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ────────────────────────────
if (!window.turnstileToken) {
const errEl = document.getElementById('formErrorGlobal');
if (errEl) {
errEl.style.display = 'block';
errEl.textContent = '🤖 Veuillez compléter le contrôle anti-robot ci-dessus avant d\'envoyer le formulaire.';
}
return;
}
// ─────────────────────────────────────────────────────────────────────────
setLoading(true); setLoading(true);
const email = form.email.value.trim(); const email = form.email.value.trim();
@ -293,8 +304,9 @@ function showSuccess(refNumber, clientData) {
// ── EMAIL DE BIENVENUE ──────────────────────────────────────────────────────── // ── EMAIL DE BIENVENUE ────────────────────────────────────────────────────────
// Envoyé via le Cloudflare Worker pour que l'adresse Paris ne soit JAMAIS // Envoyé via le Cloudflare Worker pour que l'adresse Paris ne soit JAMAIS
// présente dans le JS public. Le Worker fait le call EmailJS REST avec // présente dans le JS public. Le Worker valide d'abord le token Turnstile
// le PARIS_DEPOT_ADDRESS depuis ses variables d'environnement. // (anti-bot) puis fait le call EmailJS REST avec le PARIS_DEPOT_ADDRESS
// depuis ses variables d'environnement.
async function sendWelcomeEmail(data) { async function sendWelcomeEmail(data) {
if (!WORKER_PROXY_URL) return; if (!WORKER_PROXY_URL) return;
try { try {
@ -306,6 +318,7 @@ async function sendWelcomeEmail(data) {
firstname: data.firstname, firstname: data.firstname,
email: data.email, email: data.email,
reference_client: data.reference_client, reference_client: data.reference_client,
turnstile_token: window.turnstileToken || '',
}), }),
}); });
} catch (err) { } catch (err) {