From add43a8f1e5eb66fb06833ac30720a1ac7707bc1 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 21 Apr 2026 17:49:57 -0700 Subject: [PATCH 1/6] 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 --- .../components/contact/contact-form.tsx | 195 ++++++++++++++---- .../components/forms/landing-field.tsx | 41 ++-- apps/sim/app/(landing)/contact/page.tsx | 93 ++------- apps/sim/app/api/contact/route.ts | 64 +++++- apps/sim/lib/core/security/turnstile.ts | 105 ++++++++++ 5 files changed, 358 insertions(+), 140 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..80373bfea2 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 Link from 'next/link' import { Combobox, 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)]' + +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]' -async function submitContactRequest(payload: ContactRequestPayload) { +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,22 @@ 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 [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,7 +122,7 @@ export function ContactForm() { } } - function handleSubmit(event: React.FormEvent) { + async function handleSubmit(event: React.FormEvent) { event.preventDefault() if (contactMutation.isPending) return @@ -113,32 +144,48 @@ export function ContactForm() { 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 }) } 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 +194,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} />
- + - +