diff --git a/.env.example b/.env.example index 1c8b84f..996b95a 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,10 @@ ZOHO_CLIENT_ID= ZOHO_CLIENT_SECRET= ZOHO_REFRESH_TOKEN= ZOHO_CASES_ENABLED=false + +# Google reCAPTCHA +# Leave disabled until a real Google reCAPTCHA site key and secret key are configured. +RECAPTCHA_ENABLED=false +RECAPTCHA_SECRET_KEY= +RECAPTCHA_MIN_SCORE=0.5 +VITE_RECAPTCHA_SITE_KEY= diff --git a/server/index.js b/server/index.js index 63da28f..d7dc8e6 100644 --- a/server/index.js +++ b/server/index.js @@ -64,11 +64,12 @@ const apiLimiter = rateLimit({ const isDev = process.env.NODE_ENV === 'development' const cspDirectives = { defaultSrc: ["'self'"], - scriptSrc: ["'self'"], + scriptSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://www.gstatic.com/recaptcha/'], styleSrc: ["'self'", 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:'], connectSrc: isDev ? ["'self'", 'ws://localhost:*'] : ["'self'"], + frameSrc: ["'self'", 'https://www.google.com/recaptcha/', 'https://recaptcha.google.com/recaptcha/'], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'"], @@ -258,6 +259,7 @@ const leadSchema = z.object({ zip: z.string({ required_error: 'ZIP code is required' }).trim().min(1, 'ZIP code is required').max(10, 'ZIP code must be 10 characters or less'), message: z.string().trim().max(5000, 'Message must be 5000 characters or less').optional().or(z.literal('').transform(() => undefined)), service_interest: z.string().trim().max(50, 'Service interest must be 50 characters or less').optional().or(z.literal('').transform(() => undefined)), + recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)), company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it }) @@ -270,9 +272,77 @@ const supportSchema = z.object({ priority: z.enum(['low', 'medium', 'high'], { errorMap: () => ({ message: 'Priority must be low, medium, or high' }), }).transform((val) => val?.toLowerCase() ?? undefined).optional().or(z.literal('').transform(() => undefined)), + recaptcha_token: z.string().max(4096, 'Security verification token is too long').optional().or(z.literal('').transform(() => undefined)), company_website: z.string().optional(), // Honeypot field - bots fill this, humans don't see it }) +// --- Google reCAPTCHA Verification --- +const RECAPTCHA_ENABLED = process.env.RECAPTCHA_ENABLED === 'true' +const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY || null +const RECAPTCHA_MIN_SCORE = Number.parseFloat(process.env.RECAPTCHA_MIN_SCORE || '0.5') +const RECAPTCHA_TIMEOUT_MS = 5000 + +async function verifyRecaptcha(token, req) { + if (!RECAPTCHA_ENABLED) return { success: true } + + if (!RECAPTCHA_SECRET_KEY) { + log.error('[reCAPTCHA] RECAPTCHA_ENABLED=true but RECAPTCHA_SECRET_KEY is not configured') + return { success: false, message: 'Security verification is unavailable. Please try again later.' } + } + + if (!token) { + return { success: false, message: 'Security verification is required' } + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), RECAPTCHA_TIMEOUT_MS) + + try { + const params = new URLSearchParams({ + secret: RECAPTCHA_SECRET_KEY, + response: token, + remoteip: req.ip, + }) + + const response = await fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + signal: controller.signal, + }) + + if (!response.ok) { + log.warn(`[reCAPTCHA] Verification request failed with status ${response.status}`) + return { success: false, message: 'Security verification failed. Please try again.' } + } + + const result = await response.json() + const score = typeof result.score === 'number' ? result.score : null + const scoreAccepted = score === null || score >= RECAPTCHA_MIN_SCORE + + if (!result.success || !scoreAccepted) { + log.warn('[reCAPTCHA] Verification rejected', { + success: result.success, + score, + action: result.action, + errors: result['error-codes'], + }) + return { success: false, message: 'Security verification failed. Please try again.' } + } + + return { success: true } + } catch (err) { + if (err.name === 'AbortError') { + log.warn('[reCAPTCHA] Verification timed out') + } else { + log.error('[reCAPTCHA] Verification error:', err.message) + } + return { success: false, message: 'Security verification failed. Please try again.' } + } finally { + clearTimeout(timeoutId) + } +} + // --- Zoho CRM Forwarding (best-effort, fire-and-forget) --- const ZOHO_ENABLED = process.env.ZOHO_ENABLED === 'true' const ZOHO_CASES_ENABLED = process.env.ZOHO_CASES_ENABLED === 'true' @@ -548,7 +618,7 @@ app.get('/api/health', (req, res) => { }) // Submit lead -app.post('/api/leads', express.json({ limit: '1mb' }), (req, res) => { +app.post('/api/leads', express.json({ limit: '1mb' }), async (req, res) => { // Honeypot check - if filled, it's a bot if (req.body.company_website) { log.info('[Spam] Honeypot triggered, ignoring submission') @@ -573,6 +643,14 @@ app.post('/api/leads', express.json({ limit: '1mb' }), (req, res) => { }) } + const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req) + if (!recaptcha.success) { + return res.status(400).json({ + error: 'Validation failed', + fields: { recaptcha_token: recaptcha.message }, + }) + } + // Sanitize parsed data before insert (trim, strip tags, truncate) sanitized = sanitizePayload(parsed.data, { company: 200, @@ -626,7 +704,7 @@ app.post('/api/leads', express.json({ limit: '1mb' }), (req, res) => { }) // Submit support request -app.post('/api/support', express.json({ limit: '1mb' }), (req, res) => { +app.post('/api/support', express.json({ limit: '1mb' }), async (req, res) => { // Honeypot check - if filled, it's a bot if (req.body.company_website) { log.info('[Spam] Honeypot triggered, ignoring submission') @@ -650,6 +728,14 @@ app.post('/api/support', express.json({ limit: '1mb' }), (req, res) => { }) } + const recaptcha = await verifyRecaptcha(parsed.data.recaptcha_token, req) + if (!recaptcha.success) { + return res.status(400).json({ + error: 'Validation failed', + fields: { recaptcha_token: recaptcha.message }, + }) + } + // Sanitize parsed data before insert (trim, strip tags, truncate) const sanitized = sanitizePayload(parsed.data, { name: 100, diff --git a/src/components/RecaptchaPlaceholder.jsx b/src/components/RecaptchaPlaceholder.jsx new file mode 100644 index 0000000..bca80ec --- /dev/null +++ b/src/components/RecaptchaPlaceholder.jsx @@ -0,0 +1,16 @@ +const RecaptchaPlaceholder = ({ error = '' }) => ( +
+
+
+

Security verification

+

Google reCAPTCHA placeholder

+
+
+
+
+ {error &&

{error}

} +
+) + +export default RecaptchaPlaceholder diff --git a/src/lib/api.js b/src/lib/api.js index d996c74..c916b28 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -26,6 +26,7 @@ export async function submitSupport(data) { const errorData = await response.json().catch(() => ({})) const error = new Error(errorData.error || `API error: ${response.status}`) error.response = { status: response.status } + error.fields = errorData.fields throw error } return response.json() diff --git a/src/pages/Contact.jsx b/src/pages/Contact.jsx index c2afd23..1941a46 100644 --- a/src/pages/Contact.jsx +++ b/src/pages/Contact.jsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button' import { Input } from '@/components/ui/Input' import { Textarea } from '@/components/ui/Textarea' import { Select } from '@/components/ui/Select' +import RecaptchaPlaceholder from '@/components/RecaptchaPlaceholder' import { submitLead } from '@/lib/api' import { useDebounce } from '@/hooks/useDebounce' @@ -17,6 +18,7 @@ const Contact = () => { zip: '', message: '', service_interest: '', + recaptcha_token: '', company_website: '', }) const [errors, setErrors] = useState({ @@ -25,12 +27,13 @@ const Contact = () => { email: '', zip: '', message: '', + recaptcha_token: '', }) const debouncedErrors = useDebounce(errors, 300) const [isSubmitting, setIsSubmitting] = useState(false) const validateForm = () => { - const newErrors = { company: '', name: '', email: '', zip: '', message: '' } + const newErrors = { company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' } if (!formState.company.trim()) newErrors.company = 'Company name is required' if (!formState.name.trim()) newErrors.name = 'Name is required' if (!formState.zip.trim()) newErrors.zip = 'ZIP code is required' @@ -61,8 +64,8 @@ const Contact = () => { try { await submitLead(formState) toast.success("Thanks! We'll be in touch shortly.") - setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', company_website: '' }) - setErrors({ company: '', name: '', email: '', zip: '', message: '' }) + setFormState({ company: '', name: '', email: '', phone: '', zip: '', message: '', service_interest: '', recaptcha_token: '', company_website: '' }) + setErrors({ company: '', name: '', email: '', zip: '', message: '', recaptcha_token: '' }) } catch (error) { if (error.response?.status === 409) { toast.success("We already have your submission! We'll be in touch.") @@ -339,6 +342,8 @@ const Contact = () => { /> + +