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
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:
@@ -1,62 +1,105 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
|
||||
import { parseClientIp } from '@/lib/antispam/client-ip';
|
||||
import { createFormToken } from '@/lib/antispam/form-token';
|
||||
|
||||
const validInput = {
|
||||
process.env.FORM_TOKEN_SECRET = 'test-secret';
|
||||
|
||||
const NOW = 1_000_000_000_000;
|
||||
const TOKEN_SECRET = 'test-secret';
|
||||
|
||||
const validInput = () => ({
|
||||
honeypot: '',
|
||||
formLoadedAt: Date.now() - 10_000,
|
||||
now: Date.now(),
|
||||
formToken: createFormToken(NOW - 10_000, TOKEN_SECRET),
|
||||
now: NOW,
|
||||
ip: '1.2.3.4',
|
||||
email: 'customer@example.com',
|
||||
message: 'I would like a quote for 50 cables.',
|
||||
};
|
||||
});
|
||||
|
||||
describe('client IP extraction from X-Forwarded-For', () => {
|
||||
it('uses the last hop appended by our own proxy, not the client-controlled first hop', () => {
|
||||
expect(parseClientIp('203.0.113.7, 198.51.100.2')).toBe('198.51.100.2');
|
||||
});
|
||||
|
||||
it('handles a single hop', () => {
|
||||
expect(parseClientIp('203.0.113.7')).toBe('203.0.113.7');
|
||||
});
|
||||
|
||||
it('returns null for missing or empty headers', () => {
|
||||
expect(parseClientIp(null)).toBeNull();
|
||||
expect(parseClientIp('')).toBeNull();
|
||||
expect(parseClientIp(' ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact form anti-spam guard', () => {
|
||||
beforeEach(() => resetRateLimiter());
|
||||
|
||||
it('allows a legitimate submission', () => {
|
||||
const result = checkContactSubmission(validInput);
|
||||
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' });
|
||||
const result = checkContactSubmission({ ...validInput(), honeypot: 'http://spam.example' });
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('honeypot');
|
||||
});
|
||||
|
||||
it('blocks submissions without a form token (direct POST without loading the form)', () => {
|
||||
const result = checkContactSubmission({ ...validInput(), formToken: null });
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it('blocks submissions with a forged form token', () => {
|
||||
const result = checkContactSubmission({ ...validInput(), formToken: '1234567890.forged' });
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('invalid_token');
|
||||
});
|
||||
|
||||
it('blocks submissions sent faster than a human could fill the form', () => {
|
||||
const result = checkContactSubmission({ ...validInput, formLoadedAt: validInput.now - 1_000 });
|
||||
const freshToken = createFormToken(NOW - 1_000, TOKEN_SECRET);
|
||||
const result = checkContactSubmission({ ...validInput(), formToken: freshToken });
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('too_fast');
|
||||
});
|
||||
|
||||
it('blocks submissions with a stale token (page loaded more than a day ago)', () => {
|
||||
const staleToken = createFormToken(NOW - 25 * 60 * 60 * 1000, TOKEN_SECRET);
|
||||
const result = checkContactSubmission({ ...validInput(), formToken: staleToken });
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('expired_token');
|
||||
});
|
||||
|
||||
it('blocks submissions with an invalid email address', () => {
|
||||
const result = checkContactSubmission({ ...validInput, email: 'not-an-email' });
|
||||
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 });
|
||||
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);
|
||||
expect(checkContactSubmission(validInput()).allowed).toBe(true);
|
||||
}
|
||||
const result = checkContactSubmission(validInput);
|
||||
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);
|
||||
checkContactSubmission(validInput());
|
||||
}
|
||||
const result = checkContactSubmission({ ...validInput, ip: '5.6.7.8' });
|
||||
const result = checkContactSubmission({ ...validInput(), ip: '5.6.7.8' });
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
53
tests/form-token.test.ts
Normal file
53
tests/form-token.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createFormToken,
|
||||
verifyFormToken,
|
||||
MAX_FORM_AGE_MS,
|
||||
} from '@/lib/antispam/form-token';
|
||||
|
||||
const SECRET = 'test-secret';
|
||||
const NOW = 1_000_000_000_000;
|
||||
|
||||
describe('signed form token', () => {
|
||||
it('issues a token that verifies with its issuance timestamp', () => {
|
||||
const token = createFormToken(NOW, SECRET);
|
||||
const result = verifyFormToken(token, NOW + 10_000, SECRET);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.issuedAt).toBe(NOW);
|
||||
});
|
||||
|
||||
it('rejects a token that was tampered with', () => {
|
||||
const token = createFormToken(NOW, SECRET);
|
||||
const [issuedAt, signature] = token.split('.');
|
||||
const tampered = `${issuedAt}.${signature.slice(0, -2)}xx`;
|
||||
const result = verifyFormToken(tampered, NOW + 10_000, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token forged with a different secret', () => {
|
||||
const token = createFormToken(NOW, 'attacker-secret');
|
||||
const result = verifyFormToken(token, NOW + 10_000, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a token with a manipulated issuance timestamp', () => {
|
||||
const token = createFormToken(NOW, SECRET);
|
||||
const [, signature] = token.split('.');
|
||||
const forged = `${NOW - 60_000}.${signature}`;
|
||||
const result = verifyFormToken(forged, NOW, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed tokens', () => {
|
||||
expect(verifyFormToken('', NOW, SECRET).ok).toBe(false);
|
||||
expect(verifyFormToken('garbage', NOW, SECRET).ok).toBe(false);
|
||||
expect(verifyFormToken('123.456.789', NOW, SECRET).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects tokens older than the maximum form age', () => {
|
||||
const token = createFormToken(NOW, SECRET);
|
||||
const result = verifyFormToken(token, NOW + MAX_FORM_AGE_MS + 1, SECRET);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toBe('expired');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user