Files
e-tib.com/lib/antispam/form-token.ts
Marc Mintel 99467f1379
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
fix(antispam): harden spam guard with signed form tokens and proxy-safe IP extraction
- 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
2026-09-02 10:06:07 +02:00

65 lines
1.8 KiB
TypeScript

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