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:
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user