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:
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