diff --git a/.env.example b/.env.example index 204bfbfb2..d329c1615 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026 # Required for the Payload CMS AI Chat Agent MISTRAL_API_KEY= +# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap) +FORM_TOKEN_SECRET= + # ──────────────────────────────────────────────────────────────────────────── # Payload Infrastructure (Dockerized) # ──────────────────────────────────────────────────────────────────────────── diff --git a/app/actions/contact.ts b/app/actions/contact.ts index 08a6d688d..398be91ae 100644 --- a/app/actions/contact.ts +++ b/app/actions/contact.ts @@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import React from 'react'; import { getServerAppServices } from '@/lib/services/create-services.server'; +import { createFormToken } from '@/lib/antispam/form-token'; +import { parseClientIp } from '@/lib/antispam/client-ip'; + +/** + * Issues a server-signed form token used by the anti-spam guard to prove the + * submission originates from a real page load (server-anchored time-trap). + */ +export async function issueFormTokenAction(): Promise { + return createFormToken(Date.now()); +} export async function sendContactFormAction(formData: FormData) { const services = getServerAppServices(); @@ -25,13 +35,13 @@ export async function sendContactFormAction(formData: FormData) { // Track attempt services.analytics.track('contact-form-attempt'); - // Anti-spam guard: honeypot, time-trap, email validation, link limit, IP rate limit + // Anti-spam guard: honeypot, signed form token (server-anchored 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, + formToken: (formData.get('form_token') as string) || null, now: Date.now(), - ip: requestHeaders.get('x-forwarded-for')?.split(',')[0]?.trim() || null, + ip: parseClientIp(requestHeaders.get('x-forwarded-for')), email: (formData.get('email') as string) || null, message: (formData.get('message') as string) || null, }); diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx index a0e53ffa9..f4def9460 100644 --- a/components/ContactForm.tsx +++ b/components/ContactForm.tsx @@ -3,7 +3,7 @@ import React, { useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui'; -import { sendContactFormAction } from '@/app/actions/contact'; +import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact'; import { useAnalytics } from '@/components/analytics/useAnalytics'; import { AnalyticsEvents } from '@/components/analytics/analytics-events'; @@ -12,7 +12,18 @@ 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 [formToken, setFormToken] = useState(null); + + // Server-signed anti-spam token: proves the submission comes from a real page load + React.useEffect(() => { + let cancelled = false; + issueFormTokenAction().then((token) => { + if (!cancelled) setFormToken(token); + }); + return () => { + cancelled = true; + }; + }, []); const handleFocus = (fieldId: string) => { // Initial form start @@ -153,8 +164,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 */} - + {/* Anti-spam time-trap anchor: server-signed token issued at page load */} +
('idle'); const [hasStarted, setHasStarted] = useState(false); - const [formLoadedAt] = useState(() => Date.now()); + const [formToken, setFormToken] = useState(null); + + // Server-signed anti-spam token: proves the submission comes from a real page load + React.useEffect(() => { + let cancelled = false; + issueFormTokenAction().then((token) => { + if (!cancelled) setFormToken(token); + }); + return () => { + cancelled = true; + }; + }, []); const handleFocus = (fieldId: string) => { // Initial form start @@ -39,14 +50,13 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) }); }; - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setStatus('submitting'); - const formData = new FormData(); + // Build from the real form element so honeypot + form_token hidden inputs are included + const formData = new FormData(e.currentTarget); formData.append('name', 'Product Inquiry'); // Default name for product inquiries - formData.append('email', email); - formData.append('message', request); formData.append('productName', productName); try { @@ -175,8 +185,8 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) style={{ display: 'none' }} aria-hidden="true" /> - {/* Anti-spam time-trap: server rejects submissions faster than a human could fill the form */} - + {/* Anti-spam time-trap anchor: server-signed token issued at page load */} +
@@ -186,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps) setEmail(e.target.value)} @@ -201,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)