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

18
lib/antispam/client-ip.ts Normal file
View 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;
}

View File

@@ -3,6 +3,8 @@
* 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 RATE_LIMIT_MAX_SUBMISSIONS = 3;
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 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 {
honeypot: string | null;
formLoadedAt: number | null;
formToken: string | null;
now: number;
ip: string | null;
email: string | null;
@@ -51,11 +60,17 @@ export function checkContactSubmission(input: ContactSubmissionInput): ContactSu
return { allowed: false, reason: 'honeypot' };
}
if (
input.formLoadedAt === null ||
!Number.isFinite(input.formLoadedAt) ||
input.now - input.formLoadedAt < MIN_FILL_TIME_MS
) {
// Server-signed token: rejects direct POSTs that never loaded the form and
// anchors the time-trap to server-verified issuance time.
const token = verifyFormToken(input.formToken, input.now);
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' };
}

View 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 };
}