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
This commit is contained in:
@@ -5,3 +5,7 @@ VITE_ALLOWED_HOSTS=localhost,spotlight.cam,.spotlight.cam
|
|||||||
|
|
||||||
# Alternative: Allow all hosts (development only)
|
# Alternative: Allow all hosts (development only)
|
||||||
# VITE_ALLOWED_HOSTS=all
|
# VITE_ALLOWED_HOSTS=all
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (CAPTCHA)
|
||||||
|
# Get your keys from: https://dash.cloudflare.com/
|
||||||
|
VITE_TURNSTILE_SITE_KEY=your-site-key-here
|
||||||
|
|||||||
@@ -58,3 +58,7 @@ ENABLE_SCHEDULER=false
|
|||||||
SCHEDULER_INTERVAL_SEC=300
|
SCHEDULER_INTERVAL_SEC=300
|
||||||
# Per-event minimum time between runs in seconds (default 60s)
|
# Per-event minimum time between runs in seconds (default 60s)
|
||||||
MATCHING_MIN_INTERVAL_SEC=60
|
MATCHING_MIN_INTERVAL_SEC=60
|
||||||
|
|
||||||
|
# Cloudflare Turnstile (CAPTCHA)
|
||||||
|
# Get your secret key from: https://dash.cloudflare.com/
|
||||||
|
TURNSTILE_SECRET_KEY=your-secret-key-here
|
||||||
|
|||||||
@@ -58,3 +58,7 @@ ENABLE_SCHEDULER=false
|
|||||||
SCHEDULER_INTERVAL_SEC=300
|
SCHEDULER_INTERVAL_SEC=300
|
||||||
# Per-event minimum time between runs in seconds to avoid thrashing
|
# Per-event minimum time between runs in seconds to avoid thrashing
|
||||||
MATCHING_MIN_INTERVAL_SEC=120
|
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
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ router.post('/contact', [
|
|||||||
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
|
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('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('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
|
// 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('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'),
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Validate request
|
// 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
|
// Check if user is authenticated
|
||||||
const userId = req.user?.id || null;
|
const userId = req.user?.id || null;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ services:
|
|||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- VITE_HOST=0.0.0.0
|
- VITE_HOST=0.0.0.0
|
||||||
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-all}
|
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-all}
|
||||||
|
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
<link rel="apple-touch-startup-image" href="/splash/iphone-8.png"
|
<link rel="apple-touch-startup-image" href="/splash/iphone-8.png"
|
||||||
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" />
|
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" />
|
||||||
|
|
||||||
|
<!-- Cloudflare Turnstile -->
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
|
||||||
<title>spotlight.cam - Dance Event Video Exchange</title>
|
<title>spotlight.cam - Dance Event Video Exchange</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Send, Mail, User, MessageSquare } from 'lucide-react';
|
import { Send, Mail, User, MessageSquare } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
@@ -11,6 +11,8 @@ export default function ContactPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [turnstileToken, setTurnstileToken] = useState('');
|
||||||
|
const turnstileRef = useRef(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -20,6 +22,56 @@ export default function ContactPage() {
|
|||||||
message: '',
|
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 handleChange = (e) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
@@ -28,6 +80,13 @@ export default function ContactPage() {
|
|||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// Validate Turnstile token
|
||||||
|
if (!turnstileToken) {
|
||||||
|
setError('Please complete the CAPTCHA verification');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -36,6 +95,7 @@ export default function ContactPage() {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
subject: formData.subject,
|
subject: formData.subject,
|
||||||
message: formData.message,
|
message: formData.message,
|
||||||
|
turnstileToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add firstName and lastName only for non-logged-in users
|
// Add firstName and lastName only for non-logged-in users
|
||||||
@@ -53,6 +113,11 @@ export default function ContactPage() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.data?.error || 'Failed to submit contact form. Please try again.');
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -208,11 +273,9 @@ export default function ContactPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: CAPTCHA will go here */}
|
{/* Cloudflare Turnstile CAPTCHA */}
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
<div>
|
||||||
<p className="text-sm text-yellow-800">
|
<div ref={turnstileRef}></div>
|
||||||
CAPTCHA verification coming soon
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ server {
|
|||||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||||
|
|
||||||
# Content Security Policy (permissive for dev, tighten for production)
|
# 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)
|
# Block access to hidden files and directories (but allow .vite for development)
|
||||||
location ~ /\.(git|svn|htaccess|htpasswd|env) {
|
location ~ /\.(git|svn|htaccess|htpasswd|env) {
|
||||||
|
|||||||
Reference in New Issue
Block a user