From f3b8156557b0b418b3bed8d40770c87cadb5e6a5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?=
Date: Fri, 5 Dec 2025 18:08:05 +0100
Subject: [PATCH] feat(security): implement Cloudflare Turnstile CAPTCHA on
contact form
- Add Turnstile script to frontend/index.html
- Implement programmatic widget rendering in ContactPage
- Add backend verification via Cloudflare API
- Include client IP in verification request
- Update CSP headers to allow Cloudflare resources
- Add environment variable configuration for site and secret keys
- Pass VITE_TURNSTILE_SITE_KEY to frontend container
- Add validation and error handling for CAPTCHA tokens
---
.env.example | 4 ++
backend/.env.development.example | 4 ++
backend/.env.production.example | 4 ++
backend/src/routes/public.js | 38 ++++++++++++++-
docker-compose.yml | 1 +
frontend/index.html | 3 ++
frontend/src/pages/ContactPage.jsx | 75 +++++++++++++++++++++++++++---
nginx/conf.d/default.conf | 2 +-
8 files changed, 122 insertions(+), 9 deletions(-)
diff --git a/.env.example b/.env.example
index 4df7220..8881f4d 100644
--- a/.env.example
+++ b/.env.example
@@ -5,3 +5,7 @@ VITE_ALLOWED_HOSTS=localhost,spotlight.cam,.spotlight.cam
# Alternative: Allow all hosts (development only)
# VITE_ALLOWED_HOSTS=all
+
+# Cloudflare Turnstile (CAPTCHA)
+# Get your keys from: https://dash.cloudflare.com/
+VITE_TURNSTILE_SITE_KEY=your-site-key-here
diff --git a/backend/.env.development.example b/backend/.env.development.example
index 14ef9d1..4a01d79 100644
--- a/backend/.env.development.example
+++ b/backend/.env.development.example
@@ -58,3 +58,7 @@ ENABLE_SCHEDULER=false
SCHEDULER_INTERVAL_SEC=300
# Per-event minimum time between runs in seconds (default 60s)
MATCHING_MIN_INTERVAL_SEC=60
+
+# Cloudflare Turnstile (CAPTCHA)
+# Get your secret key from: https://dash.cloudflare.com/
+TURNSTILE_SECRET_KEY=your-secret-key-here
diff --git a/backend/.env.production.example b/backend/.env.production.example
index 65b7bc9..8db49d8 100644
--- a/backend/.env.production.example
+++ b/backend/.env.production.example
@@ -58,3 +58,7 @@ ENABLE_SCHEDULER=false
SCHEDULER_INTERVAL_SEC=300
# Per-event minimum time between runs in seconds to avoid thrashing
MATCHING_MIN_INTERVAL_SEC=120
+
+# Cloudflare Turnstile (CAPTCHA)
+# Get your secret key from: https://dash.cloudflare.com/
+TURNSTILE_SECRET_KEY=your-production-secret-key-here
diff --git a/backend/src/routes/public.js b/backend/src/routes/public.js
index aa7c572..90b71c0 100644
--- a/backend/src/routes/public.js
+++ b/backend/src/routes/public.js
@@ -57,10 +57,10 @@ router.post('/contact', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('subject').trim().isLength({ min: 3, max: 255 }).withMessage('Subject must be between 3 and 255 characters'),
body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Message must be between 10 and 5000 characters'),
+ body('turnstileToken').notEmpty().withMessage('CAPTCHA verification is required'),
// For non-logged-in users
body('firstName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('First name must be between 1 and 100 characters'),
body('lastName').optional().trim().isLength({ min: 1, max: 100 }).withMessage('Last name must be between 1 and 100 characters'),
- // TODO: Add CAPTCHA validation here
], async (req, res) => {
try {
// Validate request
@@ -73,7 +73,41 @@ router.post('/contact', [
});
}
- const { email, subject, message, firstName, lastName } = req.body;
+ const { email, subject, message, firstName, lastName, turnstileToken } = req.body;
+
+ // Verify Turnstile token
+ const turnstileSecret = process.env.TURNSTILE_SECRET_KEY;
+ const turnstileVerifyUrl = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
+
+ console.log('[Turnstile] Verifying token, secret present:', !!turnstileSecret);
+
+ try {
+ const turnstileResponse = await fetch(turnstileVerifyUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ secret: turnstileSecret,
+ response: turnstileToken,
+ remoteip: getClientIP(req),
+ }),
+ });
+
+ const turnstileResult = await turnstileResponse.json();
+ console.log('[Turnstile] Verification result:', JSON.stringify(turnstileResult));
+
+ if (!turnstileResult.success) {
+ return res.status(400).json({
+ success: false,
+ error: 'CAPTCHA verification failed. Please try again.',
+ });
+ }
+ } catch (turnstileError) {
+ console.error('Turnstile verification error:', turnstileError);
+ return res.status(500).json({
+ success: false,
+ error: 'CAPTCHA verification failed. Please try again.',
+ });
+ }
// Check if user is authenticated
const userId = req.user?.id || null;
diff --git a/docker-compose.yml b/docker-compose.yml
index 602866d..9539543 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -64,6 +64,7 @@ services:
- NODE_ENV=development
- VITE_HOST=0.0.0.0
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-all}
+ - VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
stdin_open: true
tty: true
command: npm run dev
diff --git a/frontend/index.html b/frontend/index.html
index 8696018..b40dd3c 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -30,6 +30,9 @@
+
+
+
spotlight.cam - Dance Event Video Exchange
diff --git a/frontend/src/pages/ContactPage.jsx b/frontend/src/pages/ContactPage.jsx
index 0f09077..abf0760 100644
--- a/frontend/src/pages/ContactPage.jsx
+++ b/frontend/src/pages/ContactPage.jsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Send, Mail, User, MessageSquare } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
@@ -11,6 +11,8 @@ export default function ContactPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
+ const [turnstileToken, setTurnstileToken] = useState('');
+ const turnstileRef = useRef(null);
const [formData, setFormData] = useState({
firstName: '',
@@ -20,6 +22,56 @@ export default function ContactPage() {
message: '',
});
+ // Setup Turnstile callbacks and render widget
+ useEffect(() => {
+ // Callback when Turnstile verification is successful
+ window.onTurnstileSuccess = (token) => {
+ setTurnstileToken(token);
+ };
+
+ // Callback when Turnstile encounters an error
+ window.onTurnstileError = () => {
+ setError('CAPTCHA verification failed. Please try again.');
+ setTurnstileToken('');
+ };
+
+ // Wait for Turnstile script to load and render widget
+ const renderTurnstile = () => {
+ if (window.turnstile && turnstileRef.current && !turnstileRef.current.hasChildNodes()) {
+ window.turnstile.render(turnstileRef.current, {
+ sitekey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
+ callback: window.onTurnstileSuccess,
+ 'error-callback': window.onTurnstileError,
+ theme: 'light',
+ });
+ }
+ };
+
+ // Check if script is already loaded
+ if (window.turnstile) {
+ renderTurnstile();
+ } else {
+ // Wait for script to load
+ const checkTurnstile = setInterval(() => {
+ if (window.turnstile) {
+ renderTurnstile();
+ clearInterval(checkTurnstile);
+ }
+ }, 100);
+
+ return () => {
+ clearInterval(checkTurnstile);
+ delete window.onTurnstileSuccess;
+ delete window.onTurnstileError;
+ };
+ }
+
+ return () => {
+ delete window.onTurnstileSuccess;
+ delete window.onTurnstileError;
+ };
+ }, []);
+
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -28,6 +80,13 @@ export default function ContactPage() {
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
+
+ // Validate Turnstile token
+ if (!turnstileToken) {
+ setError('Please complete the CAPTCHA verification');
+ return;
+ }
+
setLoading(true);
try {
@@ -36,6 +95,7 @@ export default function ContactPage() {
email: formData.email,
subject: formData.subject,
message: formData.message,
+ turnstileToken,
};
// Add firstName and lastName only for non-logged-in users
@@ -53,6 +113,11 @@ export default function ContactPage() {
}, 3000);
} catch (err) {
setError(err.data?.error || 'Failed to submit contact form. Please try again.');
+ // Reset Turnstile on error
+ if (window.turnstile && turnstileRef.current) {
+ window.turnstile.reset(turnstileRef.current);
+ setTurnstileToken('');
+ }
} finally {
setLoading(false);
}
@@ -208,11 +273,9 @@ export default function ContactPage() {
- {/* TODO: CAPTCHA will go here */}
-
-
- CAPTCHA verification coming soon
-
+ {/* Cloudflare Turnstile CAPTCHA */}
+
{/* Error Message */}
diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf
index e687005..7250bf1 100644
--- a/nginx/conf.d/default.conf
+++ b/nginx/conf.d/default.conf
@@ -20,7 +20,7 @@ server {
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content Security Policy (permissive for dev, tighten for production)
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' ws: wss:; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self';" always;
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' ws: wss: https://challenges.cloudflare.com; media-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src https://challenges.cloudflare.com;" always;
# Block access to hidden files and directories (but allow .vite for development)
location ~ /\.(git|svn|htaccess|htpasswd|env) {