feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap)
This commit is contained in:
76
lib/antispam/contact-guard.ts
Normal file
76
lib/antispam/contact-guard.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Server-side anti-spam guard for contact form submissions.
|
||||
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
|
||||
*/
|
||||
|
||||
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;
|
||||
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 interface ContactSubmissionInput {
|
||||
honeypot: string | null;
|
||||
formLoadedAt: number | null;
|
||||
now: number;
|
||||
ip: string | null;
|
||||
email: string | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface ContactSubmissionVerdict {
|
||||
allowed: boolean;
|
||||
reason?: SpamReason;
|
||||
}
|
||||
|
||||
const submissionTimestampsByIp = new Map<string, number[]>();
|
||||
|
||||
export function resetRateLimiter(): void {
|
||||
submissionTimestampsByIp.clear();
|
||||
}
|
||||
|
||||
function isRateLimited(ip: string, now: number): boolean {
|
||||
const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter(
|
||||
(ts) => now - ts < RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) {
|
||||
submissionTimestampsByIp.set(ip, timestamps);
|
||||
return true;
|
||||
}
|
||||
timestamps.push(now);
|
||||
submissionTimestampsByIp.set(ip, timestamps);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict {
|
||||
if (input.honeypot) {
|
||||
return { allowed: false, reason: 'honeypot' };
|
||||
}
|
||||
|
||||
if (
|
||||
input.formLoadedAt === null ||
|
||||
!Number.isFinite(input.formLoadedAt) ||
|
||||
input.now - input.formLoadedAt < MIN_FILL_TIME_MS
|
||||
) {
|
||||
return { allowed: false, reason: 'too_fast' };
|
||||
}
|
||||
|
||||
if (!input.email || !EMAIL_PATTERN.test(input.email)) {
|
||||
return { allowed: false, reason: 'invalid_email' };
|
||||
}
|
||||
|
||||
const message = input.message ?? '';
|
||||
if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) {
|
||||
return { allowed: false, reason: 'too_many_links' };
|
||||
}
|
||||
|
||||
if (input.ip && isRateLimited(input.ip, input.now)) {
|
||||
return { allowed: false, reason: 'rate_limited' };
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
Reference in New Issue
Block a user