improve anti spam

This commit is contained in:
saiminh 2026-02-17 12:31:15 +01:00
parent 39c3ffd0c5
commit 841759adcf
2 changed files with 123 additions and 27 deletions

View file

@ -12,12 +12,16 @@ const spamPatterns = [
/(?:viagra|cialis|casino|poker|loan|mortgage|credit|debt|free money|make money|work from home)/i, /(?:viagra|cialis|casino|poker|loan|mortgage|credit|debt|free money|make money|work from home)/i,
/(?:click here|buy now|limited time|act now|urgent|asap)/i, /(?:click here|buy now|limited time|act now|urgent|asap)/i,
/(?:[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}){2,}/i, // Multiple email addresses /(?:[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}){2,}/i, // Multiple email addresses
/(?:http|https|www\.)/i, // URLs in message (common in spam)
/(?:discount|sale|offer|promo|coupon|deal|cheap|affordable)/i, // Sales spam
]; ];
// Common spam keywords // Common spam keywords
const spamKeywords = [ const spamKeywords = [
'seo', 'backlink', 'guest post', 'increase traffic', 'boost sales', 'seo', 'backlink', 'guest post', 'increase traffic', 'boost sales',
'crypto', 'bitcoin', 'investment opportunity', 'get rich quick' 'crypto', 'bitcoin', 'investment opportunity', 'get rich quick',
'weight loss', 'diet pill', 'miracle', 'guaranteed', 'risk free',
'click here', 'visit our website', 'check out', 'limited offer'
]; ];
// Rate limiting: track submissions per IP (in-memory, resets on server restart) // Rate limiting: track submissions per IP (in-memory, resets on server restart)
@ -60,9 +64,9 @@ function containsSpam(content: string): boolean {
} }
} }
// Check for excessive links (more than 3 links is suspicious) // Check for excessive links (more than 2 links is suspicious)
const linkCount = (content.match(/https?:\/\//gi) || []).length; const linkCount = (content.match(/https?:\/\//gi) || []).length;
if (linkCount > 3) { if (linkCount > 2) {
return true; return true;
} }
@ -77,6 +81,18 @@ function containsSpam(content: string): boolean {
} }
} }
// Check for multiple email addresses in message (common spam pattern)
const emailMatches = content.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) || [];
if (emailMatches.length > 1) {
// Multiple emails in one message is suspicious
return true;
}
// Check for very short messages with links (common spam pattern)
if (content.length < 50 && linkCount > 0) {
return true;
}
return false; return false;
} }
@ -91,37 +107,79 @@ export const actions: Actions = {
const honeypot = (formData.get('website') ?? '').toString().trim(); const honeypot = (formData.get('website') ?? '').toString().trim();
const formLoadTimeStr = formData.get('form_load_time')?.toString(); const formLoadTimeStr = formData.get('form_load_time')?.toString();
const fields = { name, email, contact: message }; const fields = { name, email, contact: message };
const clientIP = getClientAddress();
const userAgent = request.headers.get('user-agent') || 'unknown';
// Log all form submission attempts for debugging
console.log('Form submission attempt:', {
ip: clientIP,
email: email.substring(0, 20) + '...', // Partial email for privacy
hasHoneypot: !!honeypot,
formLoadTime: formLoadTimeStr || 'missing',
userAgent: userAgent.substring(0, 50)
});
// SPAM PROTECTION CHECKS // SPAM PROTECTION CHECKS
// 1. Honeypot check - if filled, it's a bot // 1. Honeypot check - if filled, it's a bot
// Silently drop the submission to avoid teaching bots they hit a trap
if (honeypot) { if (honeypot) {
console.warn('Spam detected: Honeypot field filled', { ip: getClientAddress() }); console.warn('🚫 Spam detected: Honeypot field filled', {
return fail(400, { error: 'Invalid submission. Please try again.', fields }); ip: clientIP,
honeypotValue: honeypot,
email,
userAgent
});
// Return success response to avoid teaching bots
throw redirect(303, '/success');
} }
// 2. Rate limiting check // 2. Rate limiting check
const clientIP = getClientAddress();
if (!checkRateLimit(clientIP)) { if (!checkRateLimit(clientIP)) {
console.warn('Spam detected: Rate limit exceeded', { ip: clientIP }); console.warn('🚫 Spam detected: Rate limit exceeded', {
ip: clientIP,
email,
userAgent
});
return fail(429, { error: 'Too many submissions. Please try again later.', fields }); return fail(429, { error: 'Too many submissions. Please try again later.', fields });
} }
// 3. Time-based validation - check if form was filled too quickly (< 3 seconds) // 3. Time-based validation - check if form was filled too quickly (< 3 seconds)
if (formLoadTimeStr) { // If timestamp is missing, treat as suspicious (might be direct POST without form)
if (!formLoadTimeStr || formLoadTimeStr === '0') {
console.warn('🚫 Spam detected: Missing or invalid form timestamp', {
ip: clientIP,
email,
formLoadTimeStr: formLoadTimeStr || 'missing',
userAgent
});
return fail(400, { error: 'Invalid form submission. Please refresh the page and try again.', fields });
}
const formLoadTime = parseInt(formLoadTimeStr, 10); const formLoadTime = parseInt(formLoadTimeStr, 10);
if (isNaN(formLoadTime) || formLoadTime <= 0) {
console.warn('🚫 Spam detected: Invalid form timestamp format', {
ip: clientIP,
email,
formLoadTimeStr,
userAgent
});
return fail(400, { error: 'Invalid form submission. Please refresh the page and try again.', fields });
}
const submitTime = Date.now(); const submitTime = Date.now();
const timeSpent = submitTime - formLoadTime; const timeSpent = submitTime - formLoadTime;
// If form was submitted in less than 3 seconds, it's likely a bot // If form was submitted in less than 3 seconds, it's likely a bot
if (timeSpent < 3000) { if (timeSpent < 3000) {
console.warn('Spam detected: Form submitted too quickly', { console.warn('🚫 Spam detected: Form submitted too quickly', {
ip: clientIP, ip: clientIP,
timeSpent: `${timeSpent}ms` email,
timeSpent: `${timeSpent}ms`,
userAgent
}); });
return fail(400, { error: 'Please take your time filling out the form.', fields }); return fail(400, { error: 'Please take your time filling out the form.', fields });
} }
}
// 4. Basic validation // 4. Basic validation
if (!email || !message) { if (!email || !message) {
@ -135,7 +193,12 @@ export const actions: Actions = {
// 5. Content spam detection // 5. Content spam detection
const fullContent = `${name} ${email} ${message}`.toLowerCase(); const fullContent = `${name} ${email} ${message}`.toLowerCase();
if (containsSpam(fullContent)) { if (containsSpam(fullContent)) {
console.warn('Spam detected: Content analysis flagged submission', { ip: clientIP }); console.warn('🚫 Spam detected: Content analysis flagged submission', {
ip: clientIP,
email,
messagePreview: message.substring(0, 100),
userAgent
});
return fail(400, { error: 'Your message contains content that appears to be spam. Please revise and try again.', fields }); return fail(400, { error: 'Your message contains content that appears to be spam. Please revise and try again.', fields });
} }
@ -193,6 +256,15 @@ export const actions: Actions = {
console.error(`Brevo API error ${response.status}: ${errorText}`); console.error(`Brevo API error ${response.status}: ${errorText}`);
return fail(502, { error: 'Sending failed. Please try again later.', fields }); return fail(502, { error: 'Sending failed. Please try again later.', fields });
} }
// Log successful submission
console.log('✅ Form submission successful:', {
ip: clientIP,
email: email.substring(0, 20) + '...',
timeSpent: `${timeSpent}ms`,
messageLength: message.length
});
// Success - redirect to thank you page // Success - redirect to thank you page
} catch (err) { } catch (err) {
console.error('Brevo API request failed', err); console.error('Brevo API request failed', err);

View file

@ -13,9 +13,11 @@
let formLoadTime = 0; let formLoadTime = 0;
// Track when form becomes visible for time-based spam detection // Track when form becomes visible for time-based spam detection
$: if (contactFormVisible && formLoadTime === 0) { $: if (contactFormVisible) {
if (formLoadTime === 0) {
formLoadTime = Date.now(); formLoadTime = Date.now();
} else if (!contactFormVisible) { }
} else {
// Reset timestamp when form is closed // Reset timestamp when form is closed
formLoadTime = 0; formLoadTime = 0;
} }
@ -111,17 +113,31 @@
</label> </label>
</div> </div>
<label for="contact" class="contact-label"> <label for="contact" class="contact-label">
<textarea rows="8" name="contact" id="contact" placeholder="Your business propositions, praise, complaints and/or threats" required class="contact-input">{form?.fields?.contact ?? ''}</textarea> <textarea rows="8" name="contact" id="contact" placeholder="Your message SHOULD NOT include any website URLs or additional email addresses as this will trigger an automatic spam filter." required class="contact-input">{form?.fields?.contact ?? ''}</textarea>
</label> </label>
<!-- Honeypot field - hidden from users but bots will fill it --> <!-- Honeypot field - hidden from users but bots will fill it -->
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off" style="position: absolute; left: -9999px; opacity: 0; pointer-events: none;" aria-hidden="true"> <div class="hp-field" aria-hidden="true">
<label for="website">Website</label>
<input
type="text"
name="website"
id="website"
tabindex="-1"
autocomplete="one-time-code"
>
</div>
<!-- Timestamp for time-based validation --> <!-- Timestamp for time-based validation -->
<input type="hidden" name="form_load_time" value={formLoadTime}> <input type="hidden" name="form_load_time" value={formLoadTime}>
<div class="disclaimer max-w-(--form-maxwidth) my-4 mx-auto text-base"> <div class="disclaimer max-w-(--form-maxwidth) my-4 mx-auto text-base">
<p>Disclaimer: I will only use the data you submit here (name, email, message) to respond. I will not pass it on to any third party. If I don't hear from you I will delete the data and keep no records of it.</p> <p>Disclaimer: I will only use the data you submit here (name, email, message) to respond. I will not pass it on to any third party. If I don't hear from you I will delete the data and keep no records of it.</p>
</div> </div>
{#if form?.error} {#if form?.error}
<p class="form-error max-w-(--form-maxwidth) mx-auto mb-4 p-3 border border-(--color-text) rounded text-[0.95em] bg-white/10" role="alert" aria-live="polite">{form.error}</p> <p class="form-error max-w-(--form-maxwidth) mx-auto mb-4 p-3 border border-(--color-highlight) rounded text-[0.95em] bg-white/10 flex items-center gap-2" role="alert" aria-live="polite">
<svg class="w-8 h-8 fill-(--color-highlight) inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M320 576C178.6 576 64 461.4 64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576zM320 384C302.3 384 288 398.3 288 416C288 433.7 302.3 448 320 448C337.7 448 352 433.7 352 416C352 398.3 337.7 384 320 384zM320 192C301.8 192 287.3 207.5 288.6 225.7L296 329.7C296.9 342.3 307.4 352 319.9 352C332.5 352 342.9 342.3 343.8 329.7L351.2 225.7C352.5 207.5 338.1 192 319.8 192z"/></svg>
<span>
{form.error}
</span>
</p>
{/if} {/if}
<div class="send max-w-(--form-maxwidth) mx-auto"> <div class="send max-w-(--form-maxwidth) mx-auto">
<button class="button button--xl button--primary" type="submit">Send it!</button> <button class="button button--xl button--primary" type="submit">Send it!</button>
@ -185,4 +201,12 @@
color: var(--color-text); color: var(--color-text);
opacity: .8; opacity: .8;
} }
/* Honeypot field - hidden from users and assistive tech */
.hp-field {
position: absolute;
left: -9999px;
height: 0;
overflow: hidden;
visibility: hidden;
}
</style> </style>