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:
@@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026
|
|||||||
# Required for the Payload CMS AI Chat Agent
|
# Required for the Payload CMS AI Chat Agent
|
||||||
MISTRAL_API_KEY=
|
MISTRAL_API_KEY=
|
||||||
|
|
||||||
|
# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap)
|
||||||
|
FORM_TOKEN_SECRET=
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
# Payload Infrastructure (Dockerized)
|
# Payload Infrastructure (Dockerized)
|
||||||
# ────────────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer';
|
|||||||
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getServerAppServices } from '@/lib/services/create-services.server';
|
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<string> {
|
||||||
|
return createFormToken(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendContactFormAction(formData: FormData) {
|
export async function sendContactFormAction(formData: FormData) {
|
||||||
const services = getServerAppServices();
|
const services = getServerAppServices();
|
||||||
@@ -25,13 +35,13 @@ export async function sendContactFormAction(formData: FormData) {
|
|||||||
// Track attempt
|
// Track attempt
|
||||||
services.analytics.track('contact-form-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 { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
|
||||||
const verdict = checkContactSubmission({
|
const verdict = checkContactSubmission({
|
||||||
honeypot: (formData.get('company_website') as string) || null,
|
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(),
|
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,
|
email: (formData.get('email') as string) || null,
|
||||||
message: (formData.get('message') as string) || null,
|
message: (formData.get('message') as string) || null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
|
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 { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
@@ -12,7 +12,18 @@ 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 [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) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// Initial form start
|
||||||
@@ -153,8 +164,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 */}
|
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
|
||||||
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
<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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Input, Textarea, Button } from '@/components/ui';
|
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 { useAnalytics } from '@/components/analytics/useAnalytics';
|
||||||
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
import { AnalyticsEvents } from '@/components/analytics/analytics-events';
|
||||||
|
|
||||||
@@ -18,7 +18,18 @@ 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 [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) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
// Initial form start
|
// 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();
|
e.preventDefault();
|
||||||
setStatus('submitting');
|
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('name', 'Product Inquiry'); // Default name for product inquiries
|
||||||
formData.append('email', email);
|
|
||||||
formData.append('message', request);
|
|
||||||
formData.append('productName', productName);
|
formData.append('productName', productName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,8 +185,8 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
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 */}
|
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
|
||||||
<input type="hidden" name="form_loaded_at" value={formLoadedAt} />
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
|
|
||||||
<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">
|
||||||
@@ -186,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
id={emailId}
|
id={emailId}
|
||||||
|
name="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
@@ -201,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
|
|||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={requestId}
|
id={requestId}
|
||||||
|
name="message"
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
value={request}
|
value={request}
|
||||||
|
|||||||
@@ -3,11 +3,22 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { m } from 'framer-motion';
|
import { m } from 'framer-motion';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { sendContactFormAction } from '@/app/actions/contact';
|
import { sendContactFormAction, issueFormTokenAction } 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 [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>) => {
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -133,7 +144,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} />
|
<input type="hidden" name="form_token" value={formToken ?? ''} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
18
lib/antispam/client-ip.ts
Normal file
18
lib/antispam/client-ip.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the client IP from the X-Forwarded-For header.
|
||||||
|
*
|
||||||
|
* Our edge proxy (Traefik) APPENDS the real peer IP to any client-supplied
|
||||||
|
* chain, so the LAST hop is the only trustworthy entry. The first hop is
|
||||||
|
* attacker-controlled: reading it lets bots rotate fake IPs per request and
|
||||||
|
* defeat IP-based rate limiting entirely.
|
||||||
|
*/
|
||||||
|
export function parseClientIp(xForwardedFor: string | null | undefined): string | null {
|
||||||
|
if (!xForwardedFor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const hops = xForwardedFor
|
||||||
|
.split(',')
|
||||||
|
.map((hop) => hop.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return hops.length > 0 ? hops[hops.length - 1] : null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
|
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { verifyFormToken } from './form-token';
|
||||||
|
|
||||||
export const MIN_FILL_TIME_MS = 3_000;
|
export const MIN_FILL_TIME_MS = 3_000;
|
||||||
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
|
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
|
||||||
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
|
||||||
@@ -11,11 +13,18 @@ export const MAX_MESSAGE_LINKS = 5;
|
|||||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
|
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
|
||||||
|
|
||||||
export type SpamReason = 'honeypot' | 'too_fast' | 'invalid_email' | 'too_many_links' | 'rate_limited';
|
export type SpamReason =
|
||||||
|
| 'honeypot'
|
||||||
|
| 'invalid_token'
|
||||||
|
| 'expired_token'
|
||||||
|
| 'too_fast'
|
||||||
|
| 'invalid_email'
|
||||||
|
| 'too_many_links'
|
||||||
|
| 'rate_limited';
|
||||||
|
|
||||||
export interface ContactSubmissionInput {
|
export interface ContactSubmissionInput {
|
||||||
honeypot: string | null;
|
honeypot: string | null;
|
||||||
formLoadedAt: number | null;
|
formToken: string | null;
|
||||||
now: number;
|
now: number;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -51,11 +60,17 @@ export function checkContactSubmission(input: ContactSubmissionInput): ContactSu
|
|||||||
return { allowed: false, reason: 'honeypot' };
|
return { allowed: false, reason: 'honeypot' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Server-signed token: rejects direct POSTs that never loaded the form and
|
||||||
input.formLoadedAt === null ||
|
// anchors the time-trap to server-verified issuance time.
|
||||||
!Number.isFinite(input.formLoadedAt) ||
|
const token = verifyFormToken(input.formToken, input.now);
|
||||||
input.now - input.formLoadedAt < MIN_FILL_TIME_MS
|
if (!token.ok) {
|
||||||
) {
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: token.reason === 'expired' ? 'expired_token' : 'invalid_token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.now - token.issuedAt! < MIN_FILL_TIME_MS) {
|
||||||
return { allowed: false, reason: 'too_fast' };
|
return { allowed: false, reason: 'too_fast' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
64
lib/antispam/form-token.ts
Normal file
64
lib/antispam/form-token.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-signed form token: proves a submission originates from a real page
|
||||||
|
* load and anchors the time-trap to SERVER time instead of a client-supplied
|
||||||
|
* timestamp (which bots can forge arbitrarily).
|
||||||
|
*
|
||||||
|
* Token format: `<issuedAtMs>.<hmac-sha256(issuedAt, secret)>` (base64url).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FormTokenFailureReason = 'malformed' | 'forged' | 'expired';
|
||||||
|
|
||||||
|
export interface FormTokenVerification {
|
||||||
|
ok: boolean;
|
||||||
|
issuedAt?: number;
|
||||||
|
reason?: FormTokenFailureReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFormTokenSecret(): string {
|
||||||
|
return process.env.FORM_TOKEN_SECRET || 'insecure-dev-form-token-secret';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(issuedAt: number, secret: string): string {
|
||||||
|
return createHmac('sha256', secret).update(String(issuedAt)).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFormToken(now: number, secret: string = getFormTokenSecret()): string {
|
||||||
|
return `${now}.${sign(now, secret)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyFormToken(
|
||||||
|
token: string | null,
|
||||||
|
now: number,
|
||||||
|
secret: string = getFormTokenSecret(),
|
||||||
|
): FormTokenVerification {
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = Number(parts[0]);
|
||||||
|
if (!Number.isFinite(issuedAt) || issuedAt <= 0) {
|
||||||
|
return { ok: false, reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = Buffer.from(sign(issuedAt, secret));
|
||||||
|
const provided = Buffer.from(parts[1]);
|
||||||
|
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
|
||||||
|
return { ok: false, reason: 'forged' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const age = now - issuedAt;
|
||||||
|
if (age < 0 || age > MAX_FORM_AGE_MS) {
|
||||||
|
return { ok: false, reason: 'expired' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, issuedAt };
|
||||||
|
}
|
||||||
@@ -1,62 +1,105 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
|
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: '',
|
honeypot: '',
|
||||||
formLoadedAt: Date.now() - 10_000,
|
formToken: createFormToken(NOW - 10_000, TOKEN_SECRET),
|
||||||
now: Date.now(),
|
now: NOW,
|
||||||
ip: '1.2.3.4',
|
ip: '1.2.3.4',
|
||||||
email: 'customer@example.com',
|
email: 'customer@example.com',
|
||||||
message: 'I would like a quote for 50 cables.',
|
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', () => {
|
describe('contact form anti-spam guard', () => {
|
||||||
beforeEach(() => resetRateLimiter());
|
beforeEach(() => resetRateLimiter());
|
||||||
|
|
||||||
it('allows a legitimate submission', () => {
|
it('allows a legitimate submission', () => {
|
||||||
const result = checkContactSubmission(validInput);
|
const result = checkContactSubmission(validInput());
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks submissions where the honeypot field is filled', () => {
|
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.allowed).toBe(false);
|
||||||
expect(result.reason).toBe('honeypot');
|
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', () => {
|
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.allowed).toBe(false);
|
||||||
expect(result.reason).toBe('too_fast');
|
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', () => {
|
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.allowed).toBe(false);
|
||||||
expect(result.reason).toBe('invalid_email');
|
expect(result.reason).toBe('invalid_email');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
|
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
|
||||||
const spammy = Array.from({ length: 6 }, (_, i) => `https://spam${i}.example`).join(' ');
|
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.allowed).toBe(false);
|
||||||
expect(result.reason).toBe('too_many_links');
|
expect(result.reason).toBe('too_many_links');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
|
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
|
||||||
for (let i = 0; i < 3; i++) {
|
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.allowed).toBe(false);
|
||||||
expect(result.reason).toBe('rate_limited');
|
expect(result.reason).toBe('rate_limited');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not rate-limit different IPs independently', () => {
|
it('does not rate-limit different IPs independently', () => {
|
||||||
for (let i = 0; i < 3; i++) {
|
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);
|
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