RECAPTCHA

This commit is contained in:
null 2026-05-25 20:20:15 -05:00
parent 7f48847049
commit afec6547c1
6 changed files with 135 additions and 7 deletions

View File

@ -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=

View File

@ -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,

View File

@ -0,0 +1,16 @@
const RecaptchaPlaceholder = ({ error = '' }) => (
<div className={`rounded-md border bg-background px-4 py-3 ${error ? 'border-red-500' : 'border-border'}`}>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-primary-navy">Security verification</p>
<p className="mt-1 text-xs text-soft-text">Google reCAPTCHA placeholder</p>
</div>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border bg-white">
<span className="h-4 w-4 rounded-sm border-2 border-primary-blue" aria-hidden="true" />
</div>
</div>
{error && <p className="mt-2 text-xs text-red-500">{error}</p>}
</div>
)
export default RecaptchaPlaceholder

View File

@ -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()

View File

@ -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 = () => {
/>
</div>
<RecaptchaPlaceholder error={debouncedErrors.recaptcha_token} />
<Button type="submit" className="w-full h-11" disabled={isSubmitting}>
{isSubmitting ? (
<>

View File

@ -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 { submitSupport } from '@/lib/api'
import { useDebounce } from '@/hooks/useDebounce'
import { AlertCircle, ArrowRight, CheckCircle2, Clock3, ExternalLink, Headphones, LifeBuoy, ShieldCheck, TicketCheck, Wrench } from 'lucide-react'
@ -42,6 +43,7 @@ const Support = () => {
phone: '',
issue: '',
priority: 'medium',
recaptcha_token: '',
company_website: '', // Honeypot field - hidden from humans, bots will fill it
})
const [errors, setErrors] = useState({
@ -49,6 +51,7 @@ const Support = () => {
company: '',
email: '',
issue: '',
recaptcha_token: '',
})
// Debounce validation errors so they don't flash on every keystroke
const debouncedErrors = useDebounce(errors, 300)
@ -60,6 +63,7 @@ const Support = () => {
company: '',
email: '',
issue: '',
recaptcha_token: '',
}
// Validate required fields
@ -109,6 +113,7 @@ const Support = () => {
phone: '',
issue: '',
priority: 'medium',
recaptcha_token: '',
company_website: '',
})
setErrors({
@ -116,9 +121,15 @@ const Support = () => {
company: '',
email: '',
issue: '',
recaptcha_token: '',
})
} catch (error) {
toast.error(error.message || 'Failed to submit form. Please try again.')
if (error.response?.status === 400 && error.fields) {
setErrors(prev => ({ ...prev, ...error.fields }))
toast.error('Please fix the errors in the form')
} else {
toast.error(error.message || 'Failed to submit form. Please try again.')
}
} finally {
setIsSubmitting(false)
}
@ -414,6 +425,8 @@ const Support = () => {
/>
</div>
<RecaptchaPlaceholder error={debouncedErrors.recaptcha_token} />
<Button
type="submit"
className="w-full gap-2"