From 34cfc2689a8b4d8bdd15a612c80266a20ef94a1c Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 21 Apr 2026 18:25:48 -0700 Subject: [PATCH] improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes (#4248) * improvement(contact): add Turnstile CAPTCHA, honeypot, and robustness fixes - Add Cloudflare Turnstile with graceful degradation: when the widget fails to load (ad blockers, iOS privacy, corporate DNS), submissions fall through to a tighter rate-limit bucket rather than hard-blocking - Add honeypot field to filter automated submissions without user impact - Add separate CAPTCHA_UNAVAILABLE_RATE_LIMIT bucket (3/min) for the no-captcha path so spam via ad-blocker bypass remains expensive - Pass expectedHostname to verifyTurnstileToken to close cross-site token reuse gap - Add SITE_HOSTNAME as module-level constant (avoid URL parsing per req) - Wire onExpire/onError/onUnsupported callbacks so token expiry during slow form-filling falls back gracefully instead of showing a captcha error - Add getResponsePromise(30_000) timeout to prevent indefinite hang on network blips - Add size: 'invisible' to Turnstile options (required for execute mode) - Move turnstile.ts to lib/core/security/ alongside csp/encryption/input-validation - Switch all CSS to --landing-* variables throughout contact form - Move error display inline next to label with truncation in LandingField - Add labelClassName prop to LandingField for context-specific overrides - Simplify contact page to single-column max-w-[640px] layout * fix(contact): fall through to no-captcha rate limit on Cloudflare transport errors * chore(contact): remove extraneous comments from route * fix(contact): remove forced min-height on success state, let content flow naturally * fix(contact): cast CONTACT_TOPIC_OPTIONS to satisfy Combobox mutable type * fix(contact): disable submit during CAPTCHA resolution window, add relative to form --- .../components/contact/contact-form.tsx | 207 ++++++++++++++---- .../components/forms/landing-field.tsx | 41 ++-- apps/sim/app/(landing)/contact/page.tsx | 91 ++------ apps/sim/app/api/contact/route.ts | 79 ++++++- apps/sim/lib/core/security/turnstile.ts | 105 +++++++++ 5 files changed, 381 insertions(+), 142 deletions(-) create mode 100644 apps/sim/lib/core/security/turnstile.ts diff --git a/apps/sim/app/(landing)/components/contact/contact-form.tsx b/apps/sim/app/(landing)/components/contact/contact-form.tsx index 4cde2b3da7..11030ac760 100644 --- a/apps/sim/app/(landing)/components/contact/contact-form.tsx +++ b/apps/sim/app/(landing)/components/contact/contact-form.tsx @@ -1,10 +1,13 @@ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' +import { toError } from '@sim/utils/errors' import { useMutation } from '@tanstack/react-query' -import { Combobox, Input, Textarea } from '@/components/emcn' +import Link from 'next/link' +import { Combobox, type ComboboxOption, Input, Textarea } from '@/components/emcn' import { Check } from '@/components/emcn/icons' -import { cn } from '@/lib/core/utils/cn' +import { getEnv } from '@/lib/core/config/env' import { captureClientEvent } from '@/lib/posthog/client' import { CONTACT_TOPIC_OPTIONS, @@ -34,12 +37,28 @@ const INITIAL_FORM_STATE: ContactFormState = { message: '', } -const COMBOBOX_TOPICS = [...CONTACT_TOPIC_OPTIONS] - const LANDING_INPUT = - 'h-[36px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]' + 'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]' -async function submitContactRequest(payload: ContactRequestPayload) { +const LANDING_TEXTAREA = + 'min-h-[140px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--landing-text)] outline-none transition-colors placeholder:text-[var(--landing-text-muted)] focus:border-[var(--landing-border-strong)]' + +const LANDING_COMBOBOX = + 'h-[40px] rounded-[5px] border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] px-3 font-[430] font-season text-[14px] text-[var(--landing-text)] hover:bg-[var(--landing-bg-surface)] focus-within:border-[var(--landing-border-strong)]' + +const LANDING_SUBMIT = + 'flex h-[40px] w-full items-center justify-center rounded-[5px] border border-[var(--landing-text-subtle)] bg-[var(--landing-text-subtle)] font-[430] font-season text-[14px] text-[var(--landing-text-dark)] transition-colors hover:border-[var(--landing-bg-hover)] hover:bg-[var(--landing-bg-hover)] disabled:cursor-not-allowed disabled:opacity-60' + +const LANDING_LABEL = + 'font-[500] font-season text-[13px] text-[var(--landing-text)] tracking-[0.02em]' + +interface SubmitContactRequestInput extends ContactRequestPayload { + website: string + captchaToken?: string + captchaUnavailable?: boolean +} + +async function submitContactRequest(payload: SubmitContactRequestInput) { const response = await fetch('/api/contact', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -59,9 +78,7 @@ async function submitContactRequest(payload: ContactRequestPayload) { } export function ContactForm() { - const [form, setForm] = useState(INITIAL_FORM_STATE) - const [errors, setErrors] = useState({}) - const [submitSuccess, setSubmitSuccess] = useState(false) + const turnstileRef = useRef(null) const contactMutation = useMutation({ mutationFn: submitContactRequest, @@ -71,8 +88,23 @@ export function ContactForm() { setErrors({}) setSubmitSuccess(true) }, + onError: () => { + turnstileRef.current?.reset() + }, }) + const [form, setForm] = useState(INITIAL_FORM_STATE) + const [errors, setErrors] = useState({}) + const [submitSuccess, setSubmitSuccess] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [website, setWebsite] = useState('') + const [widgetReady, setWidgetReady] = useState(false) + const [turnstileSiteKey, setTurnstileSiteKey] = useState() + + useEffect(() => { + setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) + }, []) + function updateField( field: TField, value: ContactFormState[TField] @@ -91,9 +123,10 @@ export function ContactForm() { } } - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault() - if (contactMutation.isPending) return + if (contactMutation.isPending || isSubmitting) return + setIsSubmitting(true) const parsed = contactRequestSchema.safeParse({ ...form, @@ -110,35 +143,55 @@ export function ContactForm() { subject: fieldErrors.subject?.[0], message: fieldErrors.message?.[0], }) + setIsSubmitting(false) return } - contactMutation.mutate(parsed.data) + let captchaToken: string | undefined + let captchaUnavailable: boolean | undefined + const widget = turnstileRef.current + + if (turnstileSiteKey) { + if (widgetReady && widget) { + try { + widget.reset() + widget.execute() + captchaToken = await widget.getResponsePromise(30_000) + } catch { + captchaUnavailable = true + } + } else { + captchaUnavailable = true + } + } + + contactMutation.mutate({ ...parsed.data, website, captchaToken, captchaUnavailable }) + setIsSubmitting(false) } + const isBusy = contactMutation.isPending || isSubmitting + const submitError = contactMutation.isError - ? contactMutation.error instanceof Error - ? contactMutation.error.message - : 'Failed to send message. Please try again.' + ? toError(contactMutation.error).message || 'Failed to send message. Please try again.' : null if (submitSuccess) { return ( -
-
+
+
-

+

Message received

-

+

Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you shortly.

@@ -147,12 +200,33 @@ export function ContactForm() { } return ( -
-
- + + {/* Honeypot */} + + +
+ - +
-
- +
+ - + updateField('topic', value as ContactRequestPayload['topic'])} placeholder='Select a topic' editable={false} filterOptions={false} - className='h-[36px] rounded-[5px] px-3 font-[430] font-season text-[14px]' + className={LANDING_COMBOBOX} />
- + - +