Files
e-tib.com/lib/antispam/contact-guard.ts

77 lines
2.1 KiB
TypeScript

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