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
This commit is contained in:
Waleed
2026-04-21 18:25:48 -07:00
committed by GitHub
parent 2d94b3729d
commit 34cfc2689a
5 changed files with 381 additions and 142 deletions
@@ -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<ContactFormState>(INITIAL_FORM_STATE)
const [errors, setErrors] = useState<ContactErrors>({})
const [submitSuccess, setSubmitSuccess] = useState(false)
const turnstileRef = useRef<TurnstileInstance>(null)
const contactMutation = useMutation({
mutationFn: submitContactRequest,
@@ -71,8 +88,23 @@ export function ContactForm() {
setErrors({})
setSubmitSuccess(true)
},
onError: () => {
turnstileRef.current?.reset()
},
})
const [form, setForm] = useState<ContactFormState>(INITIAL_FORM_STATE)
const [errors, setErrors] = useState<ContactErrors>({})
const [submitSuccess, setSubmitSuccess] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [website, setWebsite] = useState('')
const [widgetReady, setWidgetReady] = useState(false)
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | undefined>()
useEffect(() => {
setTurnstileSiteKey(getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
}, [])
function updateField<TField extends keyof ContactFormState>(
field: TField,
value: ContactFormState[TField]
@@ -91,9 +123,10 @@ export function ContactForm() {
}
}
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
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 (
<div className='flex min-h-[460px] flex-col items-center justify-center rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] px-8 py-16 text-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--bg-subtle)] text-[var(--text-primary)]'>
<div className='flex flex-col items-center px-8 py-16 text-center'>
<div className='flex h-16 w-16 items-center justify-center rounded-full border border-[var(--landing-bg-elevated)] bg-[var(--landing-bg-surface)] text-[var(--landing-text)]'>
<Check className='h-8 w-8' />
</div>
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--text-primary)] leading-[1.2] tracking-[-0.02em]'>
<h2 className='mt-6 font-[430] font-season text-[24px] text-[var(--landing-text)] leading-[1.2] tracking-[-0.02em]'>
Message received
</h2>
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--text-secondary)] leading-[1.6]'>
<p className='mt-3 max-w-sm font-season text-[14px] text-[var(--landing-text-body)] leading-[1.6]'>
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
shortly.
</p>
<button
type='button'
onClick={() => setSubmitSuccess(false)}
className='mt-6 font-season text-[13px] text-[var(--text-primary)] underline underline-offset-2 transition-opacity hover:opacity-80'
className='mt-6 font-season text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
>
Send another message
</button>
@@ -147,12 +200,33 @@ export function ContactForm() {
}
return (
<form
onSubmit={handleSubmit}
className='flex flex-col gap-4 rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-5)] p-6 sm:p-8'
>
<div className='grid gap-4 sm:grid-cols-2'>
<LandingField htmlFor='contact-name' label='Name' error={errors.name}>
<form onSubmit={handleSubmit} className='relative flex flex-col gap-5'>
{/* Honeypot */}
<div
aria-hidden='true'
className='pointer-events-none absolute left-[-9999px] h-px w-px overflow-hidden opacity-0'
>
<label htmlFor='contact-website'>Website</label>
<input
id='contact-website'
name='website'
type='text'
tabIndex={-1}
autoComplete='off'
value={website}
onChange={(event) => setWebsite(event.target.value)}
data-lpignore='true'
data-1p-ignore='true'
/>
</div>
<div className='grid gap-5 sm:grid-cols-2'>
<LandingField
htmlFor='contact-name'
label='Name'
error={errors.name}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-name'
value={form.name}
@@ -161,7 +235,12 @@ export function ContactForm() {
className={LANDING_INPUT}
/>
</LandingField>
<LandingField htmlFor='contact-email' label='Email' error={errors.email}>
<LandingField
htmlFor='contact-email'
label='Email'
error={errors.email}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-email'
type='email'
@@ -173,8 +252,14 @@ export function ContactForm() {
</LandingField>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
<LandingField htmlFor='contact-company' label='Company' optional error={errors.company}>
<div className='grid gap-5 sm:grid-cols-2'>
<LandingField
htmlFor='contact-company'
label='Company'
optional
error={errors.company}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-company'
value={form.company}
@@ -183,21 +268,31 @@ export function ContactForm() {
className={LANDING_INPUT}
/>
</LandingField>
<LandingField htmlFor='contact-topic' label='Topic' error={errors.topic}>
<LandingField
htmlFor='contact-topic'
label='Topic'
error={errors.topic}
labelClassName={LANDING_LABEL}
>
<Combobox
options={COMBOBOX_TOPICS}
options={CONTACT_TOPIC_OPTIONS as unknown as ComboboxOption[]}
value={form.topic}
selectedValue={form.topic}
onChange={(value) => 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}
/>
</LandingField>
</div>
<LandingField htmlFor='contact-subject' label='Subject' error={errors.subject}>
<LandingField
htmlFor='contact-subject'
label='Subject'
error={errors.subject}
labelClassName={LANDING_LABEL}
>
<Input
id='contact-subject'
value={form.subject}
@@ -207,33 +302,53 @@ export function ContactForm() {
/>
</LandingField>
<LandingField htmlFor='contact-message' label='Message' error={errors.message}>
<LandingField
htmlFor='contact-message'
label='Message'
error={errors.message}
labelClassName={LANDING_LABEL}
>
<Textarea
id='contact-message'
value={form.message}
onChange={(event) => updateField('message', event.target.value)}
placeholder='Share details so we can help as quickly as possible'
className='min-h-[140px] rounded-[5px] border border-[var(--border-1)] bg-[var(--surface-5)] px-3 py-2.5 font-[430] font-season text-[14px] text-[var(--text-primary)] outline-none transition-colors placeholder:text-[var(--text-muted)]'
className={LANDING_TEXTAREA}
/>
</LandingField>
{turnstileSiteKey ? (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
onWidgetLoad={() => setWidgetReady(true)}
onExpire={() => setWidgetReady(false)}
onError={() => setWidgetReady(false)}
onUnsupported={() => setWidgetReady(false)}
/>
) : null}
{submitError ? (
<p role='alert' className='font-season text-[13px] text-[var(--text-error)]'>
{submitError}
</p>
) : null}
<button
type='submit'
disabled={contactMutation.isPending}
className={cn(
'flex h-[40px] w-full items-center justify-center rounded-[5px] bg-[var(--text-primary)]',
'font-[430] font-season text-[14px] text-[var(--bg)] transition-opacity',
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60'
)}
>
{contactMutation.isPending ? 'Sending...' : 'Send message'}
<button type='submit' disabled={isBusy} className={LANDING_SUBMIT}>
{isBusy ? 'Sending...' : 'Send message'}
</button>
<p className='text-center font-season text-[12px] text-[var(--landing-text-muted)] leading-[1.6]'>
By submitting, you agree to our{' '}
<Link
href='/privacy'
className='text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
>
Privacy Policy
</Link>
.
</p>
</form>
)
}
@@ -6,9 +6,21 @@ interface LandingFieldProps {
optional?: boolean
error?: string
children: React.ReactNode
/** Replaces the default label className. */
labelClassName?: string
}
export function LandingField({ label, htmlFor, optional, error, children }: LandingFieldProps) {
const DEFAULT_LABEL_CLASSNAME =
'font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
export function LandingField({
label,
htmlFor,
optional,
error,
children,
labelClassName,
}: LandingFieldProps) {
const errorId = error ? `${htmlFor}-error` : undefined
const describedChild =
errorId && isValidElement<{ 'aria-describedby'?: string; 'aria-invalid'?: boolean }>(children)
@@ -16,19 +28,22 @@ export function LandingField({ label, htmlFor, optional, error, children }: Land
: children
return (
<div className='flex flex-col gap-1.5'>
<label
htmlFor={htmlFor}
className='font-[430] font-season text-[13px] text-[var(--text-secondary)] tracking-[0.02em]'
>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</label>
<div className='flex min-h-[18px] items-baseline justify-between gap-3'>
<label htmlFor={htmlFor} className={labelClassName ?? DEFAULT_LABEL_CLASSNAME}>
{label}
{optional ? <span className='ml-1 text-[var(--text-muted)]'>(optional)</span> : null}
</label>
{error ? (
<span
id={errorId}
role='alert'
className='truncate font-season text-[12px] text-[var(--text-error)]'
>
{error}
</span>
) : null}
</div>
{describedChild}
{error ? (
<p id={errorId} role='alert' className='text-[12px] text-[var(--text-error)]'>
{error}
</p>
) : null}
</div>
)
}
+14 -77
View File
@@ -9,7 +9,7 @@ import Navbar from '@/app/(landing)/components/navbar/navbar'
export const metadata: Metadata = {
title: 'Contact Us',
description:
"Get in touch with Sim. Ask a general question, request an integration, or get help. We'll respond quickly.",
'Get in touch with Sim. Ask a general question, request an integration, or get help.',
metadataBase: new URL(SITE_URL),
alternates: { canonical: '/contact' },
openGraph: {
@@ -19,35 +19,6 @@ export const metadata: Metadata = {
},
}
interface DirectContact {
label: string
description: string
email: string
}
const DIRECT_CONTACTS: DirectContact[] = [
{
label: 'Support',
description: 'Bugs, account issues, and product help.',
email: 'help@sim.ai',
},
{
label: 'Sales',
description: 'Enterprise plans, demos, and procurement.',
email: 'enterprise@sim.ai',
},
{
label: 'Privacy',
description: 'Data requests and privacy questions.',
email: 'privacy@sim.ai',
},
{
label: 'Security',
description: 'Vulnerability reports and security disclosures.',
email: 'security@sim.ai',
},
]
export default async function ContactPage() {
const blogPosts = await getNavBlogPosts()
@@ -57,54 +28,20 @@ export default async function ContactPage() {
<Navbar blogPosts={blogPosts} />
</header>
<div className='mx-auto max-w-[1100px] px-6 pt-[72px] pb-24 sm:px-12'>
<div className='max-w-2xl'>
<span className='mb-4 block font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
Contact us
</span>
<h1 className='mb-5 text-balance font-[500] text-4xl text-[var(--landing-text)] leading-[1.05] tracking-[-0.02em] md:text-5xl'>
We're here to help
</h1>
<p className='text-[var(--landing-text-muted)] text-base leading-[1.7]'>
Got a general question, integration request, or need help? Send us a message and our
team will get back to you. For urgent issues, email the right team directly.
</p>
</div>
<div className='mx-auto max-w-[640px] px-6 pt-[72px] pb-24 sm:px-12'>
<span className='mb-4 block font-martian-mono text-[11px] text-[var(--landing-text-muted)] uppercase tracking-[0.12em]'>
Contact us
</span>
<h1 className='mb-5 text-balance font-[500] text-4xl text-[var(--landing-text)] leading-[1.05] tracking-[-0.02em] md:text-5xl'>
We're here to help
</h1>
<p className='text-pretty text-[var(--landing-text-muted)] text-base leading-[1.7]'>
Got a general question, integration request, or need help? Send us a message and our team
will get back to you.
</p>
<div className='mt-14 grid gap-10 lg:grid-cols-[1fr_1.4fr] lg:gap-16'>
<aside className='flex flex-col gap-6'>
<div>
<h2 className='mb-1 font-[500] text-[var(--landing-text)] text-lg tracking-[-0.01em]'>
Other ways to reach us
</h2>
<p className='text-[var(--landing-text-muted)] text-sm leading-[1.6]'>
Prefer email? Reach the right team directly.
</p>
</div>
<ul className='flex flex-col gap-5'>
{DIRECT_CONTACTS.map(({ label, description, email }) => (
<li
key={email}
className='border-[var(--landing-bg-elevated)] border-t pt-5 first:border-t-0 first:pt-0'
>
<p className='font-[500] text-[var(--landing-text)] text-sm'>{label}</p>
<p className='mt-1 text-[13px] text-[var(--landing-text-muted)] leading-[1.6]'>
{description}
</p>
<a
href={`mailto:${email}`}
className='mt-1.5 inline-block text-[13px] text-[var(--landing-text)] underline underline-offset-2 transition-opacity hover:opacity-80'
>
{email}
</a>
</li>
))}
</ul>
</aside>
<div className='dark'>
<ContactForm />
</div>
<div className='dark mt-14'>
<ContactForm />
</div>
</div>
+73 -6
View File
@@ -4,8 +4,9 @@ import { renderHelpConfirmationEmail } from '@/components/emails'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { getEmailDomain, SITE_URL } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
@@ -17,6 +18,7 @@ import {
const logger = createLogger('ContactAPI')
const rateLimiter = new RateLimiter()
const SITE_HOSTNAME = new URL(SITE_URL).hostname
const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
@@ -24,6 +26,14 @@ const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = {
refillIntervalMs: 60_000,
}
const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 3,
refillRate: 1,
refillIntervalMs: 60_000,
}
const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." }
export const POST = withRouteHandler(async (req: NextRequest) => {
const requestId = generateRequestId()
@@ -47,7 +57,67 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
const body = await req.json()
const body = (await req.json()) as Record<string, unknown>
const honeypot = body?.website
if (typeof honeypot === 'string' && honeypot.trim().length > 0) {
logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip })
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
}
const captchaUnavailable = body?.captchaUnavailable === true
if (captchaUnavailable) {
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
nocaptchaKey,
CAPTCHA_UNAVAILABLE_RATE_LIMIT
)
if (!nocaptchaAllowed) {
logger.warn(`[${requestId}] Rate limit exceeded (no-captcha) for IP ${ip}`)
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
)
}
}
if (isTurnstileConfigured() && !captchaUnavailable) {
const token = typeof body?.captchaToken === 'string' ? body.captchaToken : null
const verification = await verifyTurnstileToken({
token,
remoteIp: ip,
expectedHostname: SITE_HOSTNAME,
})
if (!verification.success && verification.transportError) {
logger.warn(
`[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
{ ip }
)
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
nocaptchaKey,
CAPTCHA_UNAVAILABLE_RATE_LIMIT
)
if (!nocaptchaAllowed) {
logger.warn(`[${requestId}] Rate limit exceeded (transport-error fallback) for IP ${ip}`)
return NextResponse.json(
{ error: 'Too many requests. Please try again later.' },
{ status: 429 }
)
}
} else if (!verification.success) {
logger.warn(`[${requestId}] Captcha verification failed`, {
ip,
errorCodes: verification.errorCodes,
})
return NextResponse.json(
{ error: 'Captcha verification failed. Please try again.' },
{ status: 400 }
)
}
}
const validationResult = contactRequestSchema.safeParse(body)
if (!validationResult.success) {
@@ -113,10 +183,7 @@ ${message}
logger.warn(`[${requestId}] Failed to send contact confirmation email`, err)
}
return NextResponse.json(
{ success: true, message: "Thanks — we'll be in touch soon." },
{ status: 201 }
)
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
} catch (error) {
if (error instanceof Error && error.message.includes('not configured')) {
logger.error(`[${requestId}] Email service configuration error`, error)
+105
View File
@@ -0,0 +1,105 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
const TURNSTILE_SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
const TURNSTILE_TIMEOUT_MS = 5_000
const logger = createLogger('TurnstileVerify')
interface TurnstileSiteverifyResponse {
success: boolean
'error-codes'?: string[]
challenge_ts?: string
hostname?: string
action?: string
cdata?: string
metadata?: { interactive?: boolean }
}
export interface VerifyTurnstileResult {
success: boolean
errorCodes?: string[]
/** True when the siteverify request itself failed (network/timeout). */
transportError?: boolean
}
export interface VerifyTurnstileOptions {
token: string | null | undefined
remoteIp?: string
/** Rejects the token when Cloudflare's reported hostname differs. */
expectedHostname?: string
idempotencyKey?: string
}
/**
* Verifies a Turnstile token against Cloudflare's siteverify endpoint. Tokens
* are single-use and expire after 300 seconds; never cache the result.
*/
export async function verifyTurnstileToken({
token,
remoteIp,
expectedHostname,
idempotencyKey,
}: VerifyTurnstileOptions): Promise<VerifyTurnstileResult> {
const secret = env.TURNSTILE_SECRET_KEY
if (!secret) {
logger.warn('Turnstile verification called without TURNSTILE_SECRET_KEY configured')
return { success: false, errorCodes: ['missing-input-secret'] }
}
if (!token) {
return { success: false, errorCodes: ['missing-input-response'] }
}
const body = new URLSearchParams()
body.set('secret', secret)
body.set('response', token)
if (remoteIp && remoteIp !== 'unknown') body.set('remoteip', remoteIp)
if (idempotencyKey) body.set('idempotency_key', idempotencyKey)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TURNSTILE_TIMEOUT_MS)
try {
const response = await fetch(TURNSTILE_SITEVERIFY_URL, {
method: 'POST',
body,
signal: controller.signal,
})
if (!response.ok) {
logger.warn('Turnstile siteverify returned non-2xx', { status: response.status })
return { success: false, transportError: true }
}
const data = (await response.json()) as TurnstileSiteverifyResponse
if (!data.success) {
return { success: false, errorCodes: data['error-codes'] }
}
if (expectedHostname && data.hostname && data.hostname !== expectedHostname) {
logger.warn('Turnstile hostname mismatch', {
expected: expectedHostname,
actual: data.hostname,
})
return { success: false, errorCodes: ['hostname-mismatch'] }
}
return { success: true }
} catch (err) {
const error = toError(err)
logger.warn('Turnstile siteverify request failed', {
aborted: error.name === 'AbortError',
error: error.message,
})
return { success: false, transportError: true }
} finally {
clearTimeout(timeout)
}
}
export function isTurnstileConfigured(): boolean {
return Boolean(env.TURNSTILE_SECRET_KEY)
}