feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap)
This commit is contained in:
@@ -25,11 +25,23 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
// Track attempt
|
// Track attempt
|
||||||
services.analytics.track('contact-form-attempt');
|
services.analytics.track('contact-form-attempt');
|
||||||
|
|
||||||
// Anti-spam Honeypot Check
|
// Anti-spam guard: honeypot, time-trap, email validation, link limit, IP rate limit
|
||||||
const honeypot = formData.get('company_website') as string;
|
const { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
|
||||||
if (honeypot) {
|
const verdict = checkContactSubmission({
|
||||||
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') });
|
honeypot: (formData.get('company_website') as string) || null,
|
||||||
// Silently succeed to fool the bot without doing actual work
|
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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function ContactForm() {
|
|||||||
const { trackEvent } = useAnalytics();
|
const { trackEvent } = useAnalytics();
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
const [formLoadedAt] = useState(() => Date.now());
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// Initial form start
|
||||||
@@ -152,6 +153,8 @@ export default function ContactForm() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
aria-hidden="true"
|
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} />
|
||||||
<div className="space-y-1 md:space-y-2">
|
<div className="space-y-1 md:space-y-2">
|
||||||
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
<Label htmlFor="contact-name">{t('form.name')}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
const [request, setRequest] = useState('');
|
const [request, setRequest] = useState('');
|
||||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
const [hasStarted, setHasStarted] = useState(false);
|
const [hasStarted, setHasStarted] = useState(false);
|
||||||
|
const [formLoadedAt] = useState(() => Date.now());
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// Initial form start
|
||||||
@@ -172,8 +173,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
aria-hidden="true"
|
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} />
|
||||||
|
|
||||||
<div className="space-y-2 !mt-0">
|
<div className="space-y-2 !mt-0">
|
||||||
<div className="space-y-1 !mt-0">
|
<div className="space-y-1 !mt-0">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { sendContactFormAction } from '@/app/actions/contact';
|
|||||||
|
|
||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||||
|
const [formLoadedAt] = React.useState(() => Date.now());
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -132,6 +133,7 @@ export function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||||
|
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
76
lib/antispam/contact-guard.ts
Normal file
76
lib/antispam/contact-guard.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Server-side anti-spam guard for contact form submissions.
|
||||||
|
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MIN_FILL_TIME_MS = 3_000;
|
||||||
|
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
|
||||||
|
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
||||||
|
export const MAX_MESSAGE_LINKS = 5;
|
||||||
|
|
||||||
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
|
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
|
||||||
|
|
||||||
|
export type SpamReason = 'honeypot' | 'too_fast' | 'invalid_email' | 'too_many_links' | 'rate_limited';
|
||||||
|
|
||||||
|
export interface ContactSubmissionInput {
|
||||||
|
honeypot: string | null;
|
||||||
|
formLoadedAt: number | null;
|
||||||
|
now: number;
|
||||||
|
ip: string | null;
|
||||||
|
email: string | null;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactSubmissionVerdict {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: SpamReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionTimestampsByIp = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function resetRateLimiter(): void {
|
||||||
|
submissionTimestampsByIp.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRateLimited(ip: string, now: number): boolean {
|
||||||
|
const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter(
|
||||||
|
(ts) => now - ts < RATE_LIMIT_WINDOW_MS,
|
||||||
|
);
|
||||||
|
if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) {
|
||||||
|
submissionTimestampsByIp.set(ip, timestamps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
timestamps.push(now);
|
||||||
|
submissionTimestampsByIp.set(ip, timestamps);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict {
|
||||||
|
if (input.honeypot) {
|
||||||
|
return { allowed: false, reason: 'honeypot' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.formLoadedAt === null ||
|
||||||
|
!Number.isFinite(input.formLoadedAt) ||
|
||||||
|
input.now - input.formLoadedAt < MIN_FILL_TIME_MS
|
||||||
|
) {
|
||||||
|
return { allowed: false, reason: 'too_fast' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.email || !EMAIL_PATTERN.test(input.email)) {
|
||||||
|
return { allowed: false, reason: 'invalid_email' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = input.message ?? '';
|
||||||
|
if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) {
|
||||||
|
return { allowed: false, reason: 'too_many_links' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.ip && isRateLimited(input.ip, input.now)) {
|
||||||
|
return { allowed: false, reason: 'rate_limited' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
62
tests/contact-antispam.test.ts
Normal file
62
tests/contact-antispam.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
|
||||||
|
|
||||||
|
const validInput = {
|
||||||
|
honeypot: '',
|
||||||
|
formLoadedAt: Date.now() - 10_000,
|
||||||
|
now: Date.now(),
|
||||||
|
ip: '1.2.3.4',
|
||||||
|
email: 'customer@example.com',
|
||||||
|
message: 'I would like a quote for 50 cables.',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('contact form anti-spam guard', () => {
|
||||||
|
beforeEach(() => resetRateLimiter());
|
||||||
|
|
||||||
|
it('allows a legitimate submission', () => {
|
||||||
|
const result = checkContactSubmission(validInput);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions where the honeypot field is filled', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput, honeypot: 'http://spam.example' });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('honeypot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions sent faster than a human could fill the form', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput, formLoadedAt: validInput.now - 1_000 });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('too_fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks submissions with an invalid email address', () => {
|
||||||
|
const result = checkContactSubmission({ ...validInput, email: 'not-an-email' });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('invalid_email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
|
||||||
|
const spammy = Array.from({ length: 6 }, (_, i) => `https://spam${i}.example`).join(' ');
|
||||||
|
const result = checkContactSubmission({ ...validInput, message: spammy });
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('too_many_links');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
expect(checkContactSubmission(validInput).allowed).toBe(true);
|
||||||
|
}
|
||||||
|
const result = checkContactSubmission(validInput);
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.reason).toBe('rate_limited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not rate-limit different IPs independently', () => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
checkContactSubmission(validInput);
|
||||||
|
}
|
||||||
|
const result = checkContactSubmission({ ...validInput, ip: '5.6.7.8' });
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user