From 2c51831a81de0f737c5c2b4d72a4b2a3aba80e39 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 1 Sep 2026 17:15:40 +0200 Subject: [PATCH] feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap) --- app/actions/contact.ts | 22 ++++++--- components/ContactForm.tsx | 3 ++ components/RequestQuoteForm.tsx | 7 ++- components/forms/ContactForm.tsx | 2 + lib/antispam/contact-guard.ts | 76 ++++++++++++++++++++++++++++++++ tests/contact-antispam.test.ts | 62 ++++++++++++++++++++++++++ 6 files changed, 165 insertions(+), 7 deletions(-) create mode 100644 lib/antispam/contact-guard.ts create mode 100644 tests/contact-antispam.test.ts diff --git a/app/actions/contact.ts b/app/actions/contact.ts index 43a027a98..08a6d688d 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -25,11 +25,23 @@ export async function sendContactFormAction(formData: FormData) { // Track attempt services.analytics.track('contact-form-attempt'); - // Anti-spam Honeypot Check - const honeypot = formData.get('company_website') as string; - if (honeypot) { - logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') }); - // Silently succeed to fool the bot without doing actual work + // Anti-spam guard: honeypot, time-trap, email validation, link limit, IP rate limit + const { checkContactSubmission } = await import('@/lib/antispam/contact-guard'); + const verdict = checkContactSubmission({ + honeypot: (formData.get('company_website') as string) || null, + formLoadedAt: Number(formData.get('form_loaded_at')) || null, + now: Date.now(), + ip: requestHeaders.get('x-forwarded-for')?.split(',')[0]?.trim() || null, + email: (formData.get('email') as string) || null, + message: (formData.get('message') as string) || null, + }); + + if (!verdict.allowed) { + logger.warn('Spam blocked by anti-spam guard', { + reason: verdict.reason, + email: formData.get('email'), + }); + // Silently succeed to fool bots without doing actual work return { success: true }; } diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index d74346b77..a0e53ffa9 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -12,6 +12,7 @@ export default function ContactForm() { const { trackEvent } = useAnalytics(); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [hasStarted, setHasStarted] = useState(false); + const [formLoadedAt] = useState(() => Date.now()); const handleFocus = (fieldId: string) => { // Initial form start @@ -152,6 +153,8 @@ export default function ContactForm() { style={{ display: 'none' }} aria-hidden="true" /> + {/* Anti-spam time-trap: server rejects submissions faster than a human could fill the form */} +
('idle'); const [hasStarted, setHasStarted] = useState(false); + const [formLoadedAt] = useState(() => Date.now()); const handleFocus = (fieldId: string) => { // Initial form start @@ -172,8 +173,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) tabIndex={-1} autoComplete="off" style={{ display: 'none' }} - aria-hidden="true" - /> + aria-hidden="true" + /> + {/* Anti-spam time-trap: server rejects submissions faster than a human could fill the form */} +
diff --git a/components/forms/ContactForm.tsx b/components/forms/ContactForm.tsx index 01e3f99d6..fab6efe0c 100644 --- a/components/forms/ContactForm.tsx +++ b/components/forms/ContactForm.tsx @@ -7,6 +7,7 @@ import { sendContactFormAction } from '@/app/actions/contact'; export function ContactForm() { const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [formLoadedAt] = React.useState(() => Date.now()); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -132,6 +133,7 @@ export function ContactForm() {
+