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) {