Skip to content

Commit fcdda44

Browse files
committed
fix(contact): server-authoritative captcha + review fixes
- Make captcha server-authoritative: drop the client-trusted captchaUnavailable flag; a valid Turnstile token is the only way past the stricter fallback bucket, so callers can't opt out of the challenge - Re-execute the Turnstile widget on every submit (incl. after expiry) instead of falling into the no-captcha path once the token expires - Reset the pre-submit gate on mutation settle so rapid double-clicks can't fire a duplicate /api/contact request - Map only feature_request to its email type; every other topic resolves to a General Inquiry confirmation so support requests aren't labeled bug reports - Drop the confirmation-email promise from the success copy (it's best-effort) - Collapse the duplicated no-captcha rate-limit branch; hoist shared response constants; read the Turnstile site key as a module constant
1 parent 3b8d338 commit fcdda44

3 files changed

Lines changed: 76 additions & 83 deletions

File tree

apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import { useSubmitContact } from '@/hooks/queries/contact'
2323
*/
2424
const FIELD_HEIGHT = 'h-[34px]'
2525

26+
/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */
27+
const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')
28+
2629
type ContactField = keyof ContactRequestPayload
2730
type ContactErrors = Partial<Record<ContactField, string>>
2831

@@ -105,8 +108,7 @@ export function ContactForm() {
105108
const [errors, setErrors] = useState<ContactErrors>({})
106109
const [isSubmitting, setIsSubmitting] = useState(false)
107110
const [website, setWebsite] = useState('')
108-
const [widgetReady, setWidgetReady] = useState(false)
109-
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
111+
const [widgetLoaded, setWidgetLoaded] = useState(false)
110112

111113
function updateField<TField extends keyof ContactFormState>(
112114
field: TField,
@@ -142,26 +144,25 @@ export function ContactForm() {
142144
return
143145
}
144146

147+
// Best-effort Turnstile execution: re-run the (invisible) widget on every
148+
// submit, including after an expiry, since reset + execute yields a fresh
149+
// token. If the widget never loaded or execution fails, the token is omitted
150+
// and the server applies its stricter no-captcha rate limit.
145151
let captchaToken: string | undefined
146-
let captchaUnavailable: boolean | undefined
147152
const widget = turnstileRef.current
148153

149-
if (turnstileSiteKey) {
150-
if (widgetReady && widget) {
151-
try {
152-
widget.reset()
153-
widget.execute()
154-
captchaToken = await widget.getResponsePromise(30_000)
155-
} catch {
156-
captchaUnavailable = true
157-
}
158-
} else {
159-
captchaUnavailable = true
154+
if (TURNSTILE_SITE_KEY && widgetLoaded && widget) {
155+
try {
156+
widget.reset()
157+
widget.execute()
158+
captchaToken = await widget.getResponsePromise(30_000)
159+
} catch {
160+
captchaToken = undefined
160161
}
161162
}
162163

163164
contactMutation.mutate(
164-
{ ...parsed.data, website, captchaToken, captchaUnavailable },
165+
{ ...parsed.data, website, captchaToken },
165166
{
166167
onSuccess: () => {
167168
captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic })
@@ -171,9 +172,14 @@ export function ContactForm() {
171172
onError: () => {
172173
turnstileRef.current?.reset()
173174
},
175+
// Reset the pre-submit gate only once the mutation settles, so `isBusy`
176+
// stays true continuously (the captcha window then `isPending`) and a
177+
// second click can never slip through to fire a duplicate request.
178+
onSettled: () => {
179+
setIsSubmitting(false)
180+
},
174181
}
175182
)
176-
setIsSubmitting(false)
177183
}
178184

179185
const isBusy = contactMutation.isPending || isSubmitting
@@ -190,8 +196,7 @@ export function ContactForm() {
190196
</div>
191197
<h2 className='mt-5 text-[var(--text-primary)] text-xl leading-[1.2]'>Message received</h2>
192198
<p className='mt-2 max-w-sm text-[var(--text-muted)] text-sm leading-[1.6]'>
193-
Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you
194-
shortly.
199+
Thanks for reaching out. Our team will get back to you shortly.
195200
</p>
196201
<button
197202
type='button'
@@ -311,15 +316,14 @@ export function ContactForm() {
311316
/>
312317
</ContactField>
313318

314-
{turnstileSiteKey ? (
319+
{TURNSTILE_SITE_KEY ? (
315320
<Turnstile
316321
ref={turnstileRef}
317-
siteKey={turnstileSiteKey}
322+
siteKey={TURNSTILE_SITE_KEY}
318323
options={{ execution: 'execute', appearance: 'execute', size: 'invisible' }}
319-
onWidgetLoad={() => setWidgetReady(true)}
320-
onExpire={() => setWidgetReady(false)}
321-
onError={() => setWidgetReady(false)}
322-
onUnsupported={() => setWidgetReady(false)}
324+
onWidgetLoad={() => setWidgetLoaded(true)}
325+
onError={() => setWidgetLoaded(false)}
326+
onUnsupported={() => setWidgetLoaded(false)}
323327
/>
324328
) : null}
325329

apps/sim/app/api/contact/route.ts

Lines changed: 41 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = {
3434
}
3535

3636
const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." }
37+
const TOO_MANY_REQUESTS_RESPONSE = { error: 'Too many requests. Please try again later.' }
3738

3839
export const POST = withRouteHandler(async (req: NextRequest) => {
3940
const requestId = generateRequestId()
@@ -49,13 +50,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
4950

5051
if (!allowed) {
5152
logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt })
52-
return NextResponse.json(
53-
{ error: 'Too many requests. Please try again later.' },
54-
{
55-
status: 429,
56-
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
57-
}
58-
)
53+
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, {
54+
status: 429,
55+
headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) },
56+
})
5957
}
6058

6159
const parsed = await parseRequest(submitContactContract, req, {})
@@ -72,56 +70,51 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
7270
return NextResponse.json(SUCCESS_RESPONSE, { status: 201 })
7371
}
7472

75-
const captchaUnavailable = parsed.data.body.captchaUnavailable === true
76-
77-
if (captchaUnavailable) {
78-
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
79-
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
80-
nocaptchaKey,
81-
CAPTCHA_UNAVAILABLE_RATE_LIMIT
82-
)
83-
if (!nocaptchaAllowed) {
84-
logger.warn(`[${requestId}] Rate limit exceeded (no-captcha) for IP ${ip}`)
85-
return NextResponse.json(
86-
{ error: 'Too many requests. Please try again later.' },
87-
{ status: 429 }
88-
)
73+
// Captcha is server-authoritative: a valid Turnstile token is the only way to
74+
// skip the stricter fallback bucket. A missing token (widget could not load) or
75+
// a Cloudflare transport error falls back to the tighter no-captcha rate limit
76+
// rather than a free pass, so callers cannot opt out of the challenge. An
77+
// outright invalid token is rejected.
78+
if (isTurnstileConfigured()) {
79+
let captchaVerified = false
80+
const token =
81+
typeof captchaToken === 'string' && captchaToken.length > 0 ? captchaToken : null
82+
83+
if (token) {
84+
const verification = await verifyTurnstileToken({
85+
token,
86+
remoteIp: ip,
87+
expectedHostname: SITE_HOSTNAME,
88+
})
89+
if (verification.success) {
90+
captchaVerified = true
91+
} else if (!verification.transportError) {
92+
logger.warn(`[${requestId}] Captcha verification failed`, {
93+
ip,
94+
errorCodes: verification.errorCodes,
95+
})
96+
return NextResponse.json(
97+
{ error: 'Captcha verification failed. Please try again.' },
98+
{ status: 400 }
99+
)
100+
} else {
101+
logger.warn(
102+
`[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
103+
{ ip }
104+
)
105+
}
89106
}
90-
}
91107

92-
if (isTurnstileConfigured() && !captchaUnavailable) {
93-
const token = typeof captchaToken === 'string' ? captchaToken : null
94-
const verification = await verifyTurnstileToken({
95-
token,
96-
remoteIp: ip,
97-
expectedHostname: SITE_HOSTNAME,
98-
})
99-
if (!verification.success && verification.transportError) {
100-
logger.warn(
101-
`[${requestId}] Captcha transport error, falling back to no-captcha rate limit`,
102-
{ ip }
103-
)
108+
if (!captchaVerified) {
104109
const nocaptchaKey = `public:contact:nocaptcha:${ip}`
105110
const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect(
106111
nocaptchaKey,
107112
CAPTCHA_UNAVAILABLE_RATE_LIMIT
108113
)
109114
if (!nocaptchaAllowed) {
110-
logger.warn(`[${requestId}] Rate limit exceeded (transport-error fallback) for IP ${ip}`)
111-
return NextResponse.json(
112-
{ error: 'Too many requests. Please try again later.' },
113-
{ status: 429 }
114-
)
115+
logger.warn(`[${requestId}] Rate limit exceeded (no-captcha) for IP ${ip}`)
116+
return NextResponse.json(TOO_MANY_REQUESTS_RESPONSE, { status: 429 })
115117
}
116-
} else if (!verification.success) {
117-
logger.warn(`[${requestId}] Captcha verification failed`, {
118-
ip,
119-
errorCodes: verification.errorCodes,
120-
})
121-
return NextResponse.json(
122-
{ error: 'Captcha verification failed. Please try again.' },
123-
{ status: 400 }
124-
)
125118
}
126119
}
127120

apps/sim/lib/api/contracts/contact.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ export type ContactRequestBody = z.input<typeof contactRequestSchema>
6868
export const submitContactBodySchema = contactRequestSchema.extend({
6969
website: z.string().optional(),
7070
captchaToken: z.string().optional(),
71-
captchaUnavailable: z.boolean().optional(),
7271
})
7372

7473
export function getContactTopicLabel(value: ContactRequestPayload['topic']): string {
@@ -77,17 +76,14 @@ export function getContactTopicLabel(value: ContactRequestPayload['topic']): str
7776

7877
export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other'
7978

79+
/**
80+
* Map a contact topic to the confirmation-email type. Only `feature_request` has
81+
* a matching email label ("Feature Request"); every other topic — support, sales,
82+
* billing, etc. — resolves to `other` ("General Inquiry") so the confirmation copy
83+
* never mislabels the request (e.g. calling a support inquiry a "bug report").
84+
*/
8085
export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType {
81-
switch (topic) {
82-
case 'feature_request':
83-
return 'feature_request'
84-
case 'support':
85-
return 'bug'
86-
case 'integration':
87-
return 'feedback'
88-
default:
89-
return 'other'
90-
}
86+
return topic === 'feature_request' ? 'feature_request' : 'other'
9187
}
9288

9389
export const contactResponseSchema = z.object({

0 commit comments

Comments
 (0)