From e180e211de05d399a07e615f1c31dc1ca05a4171 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 22 Apr 2026 19:27:23 +0300 Subject: [PATCH] Add honeypot to Contact form --- CONTACT_BOT_DETECTION.md | 10 ++++++++ _TODO.md | 4 +++ src/actions/contact/__tests__/action.spec.ts | 26 ++++++++++++++++++++ src/actions/contact/__tests__/domain.spec.ts | 17 +++++++++++++ src/actions/contact/action.ts | 14 ++++++++--- src/actions/contact/domain.ts | 1 + src/components/Pages/Contact/index.astro | 15 +++++++++++ 7 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 CONTACT_BOT_DETECTION.md diff --git a/CONTACT_BOT_DETECTION.md b/CONTACT_BOT_DETECTION.md new file mode 100644 index 000000000..731e6593e --- /dev/null +++ b/CONTACT_BOT_DETECTION.md @@ -0,0 +1,10 @@ +# Contact Form Bot Detection + +We added a honey pot to the contact form - a hidden input field that a real user won't see, but a bot will automatically fill out when scanning the form's HTML. + +If we still have issues: + +https://www.cloudflare.com/application-services/products/turnstile/ +https://cloud.google.com/security/products/recaptcha + + diff --git a/_TODO.md b/_TODO.md index f6c583b20..dc577ea10 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,6 +1,10 @@ # TODO +## Bot Detection + +We added a honeypot. Further options are Cloudflare Turnstile and Google Recaptcha v3. I added notes in CONTACT_BOT_DETECTION.md for implementation. + ## Chat bot tying into my phone and email Vercel AI Gateway, maybe could use for a chatbot: diff --git a/src/actions/contact/__tests__/action.spec.ts b/src/actions/contact/__tests__/action.spec.ts index 74562f3b1..549b53d8f 100644 --- a/src/actions/contact/__tests__/action.spec.ts +++ b/src/actions/contact/__tests__/action.spec.ts @@ -159,6 +159,32 @@ beforeEach(() => { }) describe('contact.submit.handler', () => { + it('silently drops submissions that fill the honeypot field', async () => { + const { contact } = await import('../action') + + const context = { + request: new Request('https://example.com/_actions/contact/submit', { + method: 'POST', + headers: { 'user-agent': 'ua-bot' }, + }), + cookies: {} as unknown, + clientAddress: '203.0.113.9', + } + + const result = await getMockedHandler(contact.submit)( + { website_url: 'https://spam.example' }, + context + ) + + expect(result).toEqual({ + success: true, + message: 'Thank you for your message. We will get back to you soon!', + }) + expect(generateEmailContentMock).not.toHaveBeenCalled() + expect(generateAcknowledgementEmailContentMock).not.toHaveBeenCalled() + expect(resendSendMock).not.toHaveBeenCalled() + }) + it('sends the admin notification and the acknowledgement email', async () => { const { contact } = await import('../action') diff --git a/src/actions/contact/__tests__/domain.spec.ts b/src/actions/contact/__tests__/domain.spec.ts index f43f3d766..7145a5cab 100644 --- a/src/actions/contact/__tests__/domain.spec.ts +++ b/src/actions/contact/__tests__/domain.spec.ts @@ -13,6 +13,7 @@ describe('contact domain validation', () => { consent: false, company: null, phone: null, + website_url: null, timeline: null, website: null, service: null, @@ -35,6 +36,22 @@ describe('contact domain validation', () => { expect(result.success).toBe(true) }) + it('accepts the honeypot field as an optional trimmed string', () => { + const result = contactFormInputSchema.safeParse({ + name: 'Jane Doe', + email: 'jane@example.com', + message: 'This is a valid message with enough detail.', + website_url: ' https://spam.example ', + }) + + expect(result.success).toBe(true) + if (!result.success) { + throw new Error('Expected schema validation to pass') + } + + expect(result.data.website_url).toBe('https://spam.example') + }) + it('rejects invalid timeline values', () => { const result = contactFormInputSchema.safeParse({ name: 'Jane Doe', diff --git a/src/actions/contact/action.ts b/src/actions/contact/action.ts index 5b48232d9..094d243ef 100644 --- a/src/actions/contact/action.ts +++ b/src/actions/contact/action.ts @@ -25,6 +25,11 @@ import { parseAttachmentsFromInput, } from './responder' +const successResponse = { + success: true, + message: 'Thank you for your message. We will get back to you soon!', +} as const + async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise { if (!isProd()) { return @@ -99,6 +104,10 @@ export const contact = { }) } + if (input.website_url) { + return successResponse + } + const formData = getFormDataFromInput(input) const files = await parseAttachmentsFromInput(input) @@ -181,10 +190,7 @@ export const contact = { } } - return { - success: true, - message: 'Thank you for your message. We will get back to you soon!', - } + return successResponse } catch (error) { if (error instanceof ActionError) { throw error diff --git a/src/actions/contact/domain.ts b/src/actions/contact/domain.ts index 262977ce9..b33a5182f 100644 --- a/src/actions/contact/domain.ts +++ b/src/actions/contact/domain.ts @@ -49,6 +49,7 @@ export const contactFormInputSchema = z company: optionalTrimmedString(100), phone: optionalTrimmedString(50), project_type: optionalTrimmedString(50), + website_url: optionalTrimmedString(200), budget: z.preprocess( value => emptyStringToUndefined(trimString(value)), z.enum(['5k-10k', '10k-25k', '25k-50k', '50k+']).optional() diff --git a/src/components/Pages/Contact/index.astro b/src/components/Pages/Contact/index.astro index 400a67981..a99749536 100644 --- a/src/components/Pages/Contact/index.astro +++ b/src/components/Pages/Contact/index.astro @@ -151,6 +151,21 @@ const { content, prefilledProjectType = '' } = Astro.props {content.form.formErrorMssg} + + {/** Personal Information */}