Skip to content

Commit 901f049

Browse files
authored
Merge pull request #658 from webstackdev/feature/contact-form-bot-protection
Add honeypot to Contact form
2 parents 733df8a + e180e21 commit 901f049

7 files changed

Lines changed: 83 additions & 4 deletions

File tree

CONTACT_BOT_DETECTION.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Contact Form Bot Detection
2+
3+
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.
4+
5+
If we still have issues:
6+
7+
https://www.cloudflare.com/application-services/products/turnstile/
8+
https://cloud.google.com/security/products/recaptcha
9+
10+

_TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<!-- markdownlint-disable-file -->
22
# TODO
33

4+
## Bot Detection
5+
6+
We added a honeypot. Further options are Cloudflare Turnstile and Google Recaptcha v3. I added notes in CONTACT_BOT_DETECTION.md for implementation.
7+
48
## Chat bot tying into my phone and email
59

610
Vercel AI Gateway, maybe could use for a chatbot:

src/actions/contact/__tests__/action.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,32 @@ beforeEach(() => {
159159
})
160160

161161
describe('contact.submit.handler', () => {
162+
it('silently drops submissions that fill the honeypot field', async () => {
163+
const { contact } = await import('../action')
164+
165+
const context = {
166+
request: new Request('https://example.com/_actions/contact/submit', {
167+
method: 'POST',
168+
headers: { 'user-agent': 'ua-bot' },
169+
}),
170+
cookies: {} as unknown,
171+
clientAddress: '203.0.113.9',
172+
}
173+
174+
const result = await getMockedHandler<ContactSubmitInput, ContactSubmitOutput>(contact.submit)(
175+
{ website_url: 'https://spam.example' },
176+
context
177+
)
178+
179+
expect(result).toEqual({
180+
success: true,
181+
message: 'Thank you for your message. We will get back to you soon!',
182+
})
183+
expect(generateEmailContentMock).not.toHaveBeenCalled()
184+
expect(generateAcknowledgementEmailContentMock).not.toHaveBeenCalled()
185+
expect(resendSendMock).not.toHaveBeenCalled()
186+
})
187+
162188
it('sends the admin notification and the acknowledgement email', async () => {
163189
const { contact } = await import('../action')
164190

src/actions/contact/__tests__/domain.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('contact domain validation', () => {
1313
consent: false,
1414
company: null,
1515
phone: null,
16+
website_url: null,
1617
timeline: null,
1718
website: null,
1819
service: null,
@@ -35,6 +36,22 @@ describe('contact domain validation', () => {
3536
expect(result.success).toBe(true)
3637
})
3738

39+
it('accepts the honeypot field as an optional trimmed string', () => {
40+
const result = contactFormInputSchema.safeParse({
41+
name: 'Jane Doe',
42+
email: 'jane@example.com',
43+
message: 'This is a valid message with enough detail.',
44+
website_url: ' https://spam.example ',
45+
})
46+
47+
expect(result.success).toBe(true)
48+
if (!result.success) {
49+
throw new Error('Expected schema validation to pass')
50+
}
51+
52+
expect(result.data.website_url).toBe('https://spam.example')
53+
})
54+
3855
it('rejects invalid timeline values', () => {
3956
const result = contactFormInputSchema.safeParse({
4057
name: 'Jane Doe',

src/actions/contact/action.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {
2525
parseAttachmentsFromInput,
2626
} from './responder'
2727

28+
const successResponse = {
29+
success: true,
30+
message: 'Thank you for your message. We will get back to you soon!',
31+
} as const
32+
2833
async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise<void> {
2934
if (!isProd()) {
3035
return
@@ -99,6 +104,10 @@ export const contact = {
99104
})
100105
}
101106

107+
if (input.website_url) {
108+
return successResponse
109+
}
110+
102111
const formData = getFormDataFromInput(input)
103112
const files = await parseAttachmentsFromInput(input)
104113

@@ -181,10 +190,7 @@ export const contact = {
181190
}
182191
}
183192

184-
return {
185-
success: true,
186-
message: 'Thank you for your message. We will get back to you soon!',
187-
}
193+
return successResponse
188194
} catch (error) {
189195
if (error instanceof ActionError) {
190196
throw error

src/actions/contact/domain.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const contactFormInputSchema = z
4949
company: optionalTrimmedString(100),
5050
phone: optionalTrimmedString(50),
5151
project_type: optionalTrimmedString(50),
52+
website_url: optionalTrimmedString(200),
5253
budget: z.preprocess(
5354
value => emptyStringToUndefined(trimString(value)),
5455
z.enum(['5k-10k', '10k-25k', '25k-50k', '50k+']).optional()

src/components/Pages/Contact/index.astro

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ const { content, prefilledProjectType = '' } = Astro.props
151151
{content.form.formErrorMssg}
152152
</div>
153153

154+
<div
155+
class="absolute h-px w-px overflow-hidden opacity-0 pointer-events-none"
156+
aria-hidden="true"
157+
>
158+
<label for="website_url">Website</label>
159+
<input
160+
type="text"
161+
id="website_url"
162+
name="website_url"
163+
tabindex="-1"
164+
autocomplete="off"
165+
inputmode="url"
166+
/>
167+
</div>
168+
154169
{/** Personal Information */}
155170
<div class="border-b border-trim">
156171
<h3 class="flex items-center text-page-inverse text-xl font-semibold mb-6">

0 commit comments

Comments
 (0)