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:
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).
|
||||
*/
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user