fix(antispam): harden spam guard with signed form tokens and proxy-safe IP extraction
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m33s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 2s

- Rate limit now uses the last X-Forwarded-For hop (appended by Traefik)
  instead of the client-controlled first hop, which bots rotated freely
- Time-trap anchored to server time via HMAC-signed form token
  (FORM_TOKEN_SECRET) instead of client-supplied form_loaded_at
- Fix RequestQuoteForm regression: FormData was built from scratch without
  honeypot/token hidden inputs and inputs lacked name attributes, so every
  legitimate quote request was silently blocked as spam
This commit is contained in:
2026-09-02 10:06:07 +02:00
parent 6b3f9b9b25
commit 99467f1379
10 changed files with 278 additions and 38 deletions

View File

@@ -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<string | null>(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 */}
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label>
<Input

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } 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';
@@ -18,7 +18,18 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false);
const [formLoadedAt] = useState(() => Date.now());
const [formToken, setFormToken] = useState<string | null>(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<HTMLFormElement>) => {
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 */}
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0">
@@ -186,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<Input
type="email"
id={emailId}
name="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
@@ -201,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
</label>
<Textarea
id={requestId}
name="message"
required
rows={3}
value={request}

View File

@@ -3,11 +3,22 @@
import * as React from 'react';
import { m } from 'framer-motion';
import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact';
import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
export function ContactForm() {
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [formLoadedAt] = React.useState(() => Date.now());
const [formToken, setFormToken] = React.useState<string | null>(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 handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -133,7 +144,7 @@ export function ContactForm() {
</div>
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
<input type="hidden" name="form_token" value={formToken ?? ''} />
<Button
type="submit"