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