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
65 lines
1.8 KiB
TypeScript
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 };
|
|
}
|