RECAPTCHA
This commit is contained in:
parent
7f48847049
commit
afec6547c1
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue