improved on contact form security

This commit is contained in:
2025-05-24 14:58:36 -07:00
parent 5b18bf8d53
commit 955241c570
3 changed files with 263 additions and 61 deletions

View File

@@ -39,3 +39,13 @@ asdasdMessage sent successfully! We'll get back to you soon.
asdasdMessage sent successfully! We'll get back to you soon. asdasdMessage sent successfully! We'll get back to you soon.
asdasdMessage sent successfully! We'll get back to you soon. asdasdMessage sent successfully! We'll get back to you soon.
============================== ==============================
=== New Message 2025-05-24T21:57:31.179Z ===
From: Stephan (as@ju.com)
Phone: 12312311
Subject: asdasd
IP: unknown
User Agent: Mozilla/5.0 (X11; Linux x86_64; rv:138.0) Gecko/20100101 Firefox/138.0
Absolutely! Right now your API returns a fake "success" message for spam to avoid revealing the spam detection to attackers. Let me modify it to give proper feedback for legitimate validation errors while still hiding spam detection.
==============================

View File

@@ -2,13 +2,54 @@
export const prerender = false; export const prerender = false;
// Simple in-memory rate limiting (for single server) // Simple in-memory rate limiting (for single server)
// For production with multiple servers, consider Redis or database storage
const rateLimitMap = new Map(); const rateLimitMap = new Map();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes const RATE_LIMIT_DETECTION_WINDOW = 15 * 60 * 1000; // 15 minutes - window to detect abuse
const RATE_LIMIT_BLOCK_DURATION = 60 * 60 * 1000; // 1 hour - how long to block after abuse detected
const MAX_REQUESTS = 3; // Max 3 submissions per 15 minutes per IP const MAX_REQUESTS = 3; // Max 3 submissions per 15 minutes per IP
// Attack monitoring
const attackLog = [];
const MAX_ATTACK_LOG_SIZE = 1000;
function logSuspiciousActivity(ip, reason, data = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
ip,
reason,
userAgent: data.userAgent || 'unknown',
data: data.formData || {}
};
attackLog.push(logEntry);
// Keep log size manageable
if (attackLog.length > MAX_ATTACK_LOG_SIZE) {
attackLog.shift();
}
// Log to console for real-time monitoring
console.log(`🚨 SUSPICIOUS ACTIVITY: ${reason} from ${ip}`, logEntry);
// Log to file for persistence
try {
const fs = require('fs');
const path = require('path');
const logDir = path.resolve(process.cwd(), 'logs');
const attackLogFile = path.join(logDir, 'security-attacks.log');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logMessage = `${logEntry.timestamp} - ${reason} from ${ip} - UA: ${logEntry.userAgent}\n`;
fs.appendFileSync(attackLogFile, logMessage);
} catch (error) {
console.error('Failed to log attack:', error);
}
}
function getRealIP(request) { function getRealIP(request) {
// Get real IP from various headers (useful behind nginx proxy)
const forwarded = request.headers.get('x-forwarded-for'); const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip'); const realIP = request.headers.get('x-real-ip');
const cfIP = request.headers.get('cf-connecting-ip'); const cfIP = request.headers.get('cf-connecting-ip');
@@ -23,91 +64,213 @@ function getRealIP(request) {
return cfIP; return cfIP;
} }
// Fallback
return 'unknown'; return 'unknown';
} }
function isRateLimited(ip) { function isRateLimited(ip) {
const now = Date.now(); const now = Date.now();
const userRequests = rateLimitMap.get(ip) || []; const userData = rateLimitMap.get(ip) || { requests: [], blockedUntil: null };
// Clean old requests // Check if IP is currently blocked
const recentRequests = userRequests.filter(time => now - time < RATE_LIMIT_WINDOW); if (userData.blockedUntil && now < userData.blockedUntil) {
const remainingTime = Math.ceil((userData.blockedUntil - now) / 1000 / 60); // minutes
if (recentRequests.length >= MAX_REQUESTS) { logSuspiciousActivity(ip, 'BLOCKED_IP_ATTEMPT', {
remainingBlockTime: remainingTime + ' minutes',
blockedUntil: new Date(userData.blockedUntil).toISOString()
});
return true; return true;
} }
// Add current request // Clean old requests (only keep requests from the detection window)
const recentRequests = userData.requests.filter(time => now - time < RATE_LIMIT_DETECTION_WINDOW);
// Check if this request would exceed the limit
if (recentRequests.length >= MAX_REQUESTS) {
// Block the IP for 1 hour
const blockUntil = now + RATE_LIMIT_BLOCK_DURATION;
userData.blockedUntil = blockUntil;
userData.requests = recentRequests; // Keep the requests that triggered the block
rateLimitMap.set(ip, userData);
logSuspiciousActivity(ip, 'RATE_LIMIT_EXCEEDED_BLOCKED', {
requestCount: recentRequests.length,
detectionWindow: RATE_LIMIT_DETECTION_WINDOW / 1000 / 60 + ' minutes',
blockDuration: RATE_LIMIT_BLOCK_DURATION / 1000 / 60 + ' minutes',
blockedUntil: new Date(blockUntil).toISOString()
});
return true;
}
// Add current request and update the map
recentRequests.push(now); recentRequests.push(now);
rateLimitMap.set(ip, recentRequests); userData.requests = recentRequests;
userData.blockedUntil = null; // Clear any previous block
rateLimitMap.set(ip, userData);
return false; return false;
} }
// Simple honeypot and spam detection function validateMessage(data, ip, userAgent) {
function detectSpam(data) { const { name, email, subject, message } = data;
const { name, email, subject, message, website } = data;
// Check for honeypot field - if filled, it's likely a bot // Check message length first (legitimate validation errors)
if (website && website.trim() !== '') { if (message.length < 10) {
return "Honeypot triggered"; logSuspiciousActivity(ip, 'MESSAGE_TOO_SHORT', {
userAgent,
messageLength: message.length,
formData: { name, email, subject }
});
return {
type: 'validation_error',
message: 'Your message is too short. Please provide at least 10 characters.',
logReason: 'MESSAGE_TOO_SHORT'
};
} }
// Check for suspicious patterns if (message.length > 5000) {
logSuspiciousActivity(ip, 'MESSAGE_TOO_LONG', {
userAgent,
messageLength: message.length,
formData: { name, email, subject }
});
return {
type: 'validation_error',
message: 'Your message is too long. Please keep it under 5000 characters.',
logReason: 'MESSAGE_TOO_LONG'
};
}
// Check for excessive links (could be legitimate user error)
const linkCount = (message.match(/http[s]?:\/\//g) || []).length;
if (linkCount > 2) {
logSuspiciousActivity(ip, 'EXCESSIVE_LINKS', {
userAgent,
linkCount,
formData: { name, email, subject }
});
return {
type: 'validation_error',
message: 'Please limit your message to maximum 2 links.',
logReason: 'EXCESSIVE_LINKS'
};
}
return null; // No validation errors
}
function detectSpam(data, ip, userAgent) {
const { name, email, subject, message, website } = data;
// Check for honeypot field (definitely spam - hide detection)
if (website && website.trim() !== '') {
logSuspiciousActivity(ip, 'HONEYPOT_TRIGGERED', {
userAgent,
formData: { name, email, subject, honeypotValue: website }
});
return {
type: 'spam_hidden',
message: 'Thank you for your message! We\'ll get back to you soon.',
logReason: 'HONEYPOT_TRIGGERED'
};
}
// Check for suspicious patterns (spam - hide detection)
const suspiciousPatterns = [ const suspiciousPatterns = [
/viagra|cialis|casino|poker|loan|mortgage|bitcoin|crypto/i, { pattern: /viagra|cialis|casino|poker|loan|mortgage|bitcoin|crypto/i, name: 'SPAM_KEYWORDS' },
/\b(click here|buy now|limited time|act now)\b/i, { pattern: /\b(click here|buy now|limited time|act now)\b/i, name: 'MARKETING_SPAM' },
/http[s]?:\/\/.*\.(tk|ml|ga|cf|bit\.ly)/i, // Suspicious domains { pattern: /http[s]?:\/\/.*\.(tk|ml|ga|cf|bit\.ly)/i, name: 'SUSPICIOUS_DOMAINS' },
/\b(seo|backlink|link building|rank higher)\b/i, { pattern: /\b(seo|backlink|link building|rank higher)\b/i, name: 'SEO_SPAM' },
]; ];
const fullText = `${name} ${email} ${subject} ${message}`.toLowerCase(); const fullText = `${name} ${email} ${subject} ${message}`.toLowerCase();
for (const pattern of suspiciousPatterns) { for (const { pattern, name: patternName } of suspiciousPatterns) {
if (pattern.test(fullText)) { if (pattern.test(fullText)) {
return "Suspicious content detected"; logSuspiciousActivity(ip, patternName, {
userAgent,
formData: { name, email, subject, messageLength: message.length }
});
return {
type: 'spam_hidden',
message: 'Thank you for your message! We\'ll get back to you soon.',
logReason: patternName
};
} }
} }
// Check for excessive links // Check for repeated characters (likely spam - hide detection)
const linkCount = (message.match(/http[s]?:\/\//g) || []).length;
if (linkCount > 2) {
return "Too many links";
}
// Check message length (too short or too long)
if (message.length < 10) {
return "Message too short";
}
if (message.length > 5000) {
return "Message too long";
}
// Check for repeated characters (common in spam)
if (/(.)\1{10,}/.test(message)) { if (/(.)\1{10,}/.test(message)) {
return "Suspicious character repetition"; logSuspiciousActivity(ip, 'SUSPICIOUS_REPETITION', {
userAgent,
formData: { name, email, subject }
});
return {
type: 'spam_hidden',
message: 'Thank you for your message! We\'ll get back to you soon.',
logReason: 'SUSPICIOUS_REPETITION'
};
} }
return null; return null; // No spam detected
} }
// API endpoint to view recent attacks (for your monitoring)
export const GET = async ({ request }) => {
// Simple auth check - only allow from localhost or your IP
const clientIP = getRealIP(request);
// You can add your own IP here for remote monitoring
const allowedIPs = ['127.0.0.1', '::1', 'localhost'];
if (!allowedIPs.includes(clientIP) && !clientIP.startsWith('10.0.0.')) {
return new Response('Unauthorized', { status: 401 });
}
const recentAttacks = attackLog.slice(-50); // Last 50 attacks
const rateLimitStats = Array.from(rateLimitMap.entries()).map(([ip, userData]) => {
const now = Date.now();
const isCurrentlyBlocked = userData.blockedUntil && now < userData.blockedUntil;
return {
ip,
requestCount: userData.requests ? userData.requests.length : 0,
lastRequest: userData.requests && userData.requests.length > 0
? new Date(Math.max(...userData.requests)).toISOString()
: null,
isBlocked: isCurrentlyBlocked,
blockedUntil: userData.blockedUntil ? new Date(userData.blockedUntil).toISOString() : null,
remainingBlockTime: isCurrentlyBlocked
? Math.ceil((userData.blockedUntil - now) / 1000 / 60) + ' minutes'
: null
};
});
return new Response(JSON.stringify({
recentAttacks,
rateLimitStats,
totalAttacksLogged: attackLog.length
}, null, 2), {
headers: { 'Content-Type': 'application/json' }
});
};
export const POST = async ({ request }) => { export const POST = async ({ request }) => {
try { try {
// Get client IP
const clientIP = getRealIP(request); const clientIP = getRealIP(request);
console.log(`Contact form submission from IP: ${clientIP}`); const userAgent = request.headers.get('user-agent') || 'unknown';
console.log(`Contact form submission from IP: ${clientIP}, UA: ${userAgent}`);
// Check rate limiting // Check rate limiting
if (isRateLimited(clientIP)) { if (isRateLimited(clientIP)) {
console.log(`Rate limit exceeded for IP: ${clientIP}`); console.log(`🚫 Rate limit exceeded for IP: ${clientIP}`);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Too many requests. Please wait before submitting again.' error: 'Too many requests. Please wait before submitting again.'
}), { }), {
status: 429, status: 429,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Retry-After': '900' // 15 minutes 'Retry-After': '3600' // 1 hour in seconds
} }
}); });
} }
@@ -118,6 +281,7 @@ export const POST = async ({ request }) => {
data = await request.json(); data = await request.json();
console.log('Received form data:', { ...data, website: data.website ? '[FILLED]' : '[EMPTY]' }); console.log('Received form data:', { ...data, website: data.website ? '[FILLED]' : '[EMPTY]' });
} catch (parseError) { } catch (parseError) {
logSuspiciousActivity(clientIP, 'INVALID_JSON', { userAgent });
console.error('Failed to parse request body:', parseError); console.error('Failed to parse request body:', parseError);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Invalid request format' error: 'Invalid request format'
@@ -131,8 +295,12 @@ export const POST = async ({ request }) => {
const { name, email, phone, subject, message } = data; const { name, email, phone, subject, message } = data;
// Validate the form data // Validate required fields
if (!name || !email || !subject || !message) { if (!name || !email || !subject || !message) {
logSuspiciousActivity(clientIP, 'MISSING_REQUIRED_FIELDS', {
userAgent,
formData: { name: !!name, email: !!email, subject: !!subject, message: !!message }
});
console.log('Missing required fields'); console.log('Missing required fields');
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'All required fields must be filled' error: 'All required fields must be filled'
@@ -144,9 +312,13 @@ export const POST = async ({ request }) => {
}); });
} }
// Basic email validation // Email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
logSuspiciousActivity(clientIP, 'INVALID_EMAIL', {
userAgent,
formData: { name, email, subject }
});
console.log('Invalid email format:', email); console.log('Invalid email format:', email);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Please provide a valid email address' error: 'Please provide a valid email address'
@@ -158,15 +330,31 @@ export const POST = async ({ request }) => {
}); });
} }
// Spam detection // First check for validation errors
const spamReason = detectSpam(data); const validationError = validateMessage(data, clientIP, userAgent);
if (spamReason) { if (validationError) {
// Log spam attempt but don't reveal detection to user const { message: responseMessage, logReason } = validationError;
console.log(`Spam detected from ${clientIP}: ${spamReason}`);
// Return success to avoid revealing spam detection console.log(`❌ Validation error from ${clientIP}: ${logReason}`);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true error: responseMessage
}), {
status: 400,
headers: {
'Content-Type': 'application/json'
}
});
}
// Then check for spam
const spamResult = detectSpam(data, clientIP, userAgent);
if (spamResult) {
const { message: responseMessage, logReason } = spamResult;
console.log(`🚨 Spam detected from ${clientIP}: ${logReason}`);
return new Response(JSON.stringify({
success: true,
message: responseMessage
}), { }), {
status: 200, status: 200,
headers: { headers: {
@@ -207,7 +395,8 @@ ${message}
**Name:** ${name} **Name:** ${name}
**Email:** ${email} **Email:** ${email}
**Phone:** ${phone || 'Not provided'} **Phone:** ${phone || 'Not provided'}
**IP:** ${clientIP}`; **IP:** ${clientIP}
**User Agent:** ${userAgent}`;
// Prepare headers with Bearer token authentication // Prepare headers with Bearer token authentication
const headers = { const headers = {
@@ -246,9 +435,9 @@ ${message}
}); });
} }
console.log('Message sent successfully to ntfy'); console.log('✅ Legitimate message sent successfully from', clientIP);
// Add file logging as a backup // File logging
try { try {
const fs = await import('fs'); const fs = await import('fs');
const path = await import('path'); const path = await import('path');
@@ -266,6 +455,7 @@ From: ${name} (${email})
Phone: ${phone || 'Not provided'} Phone: ${phone || 'Not provided'}
Subject: ${subject} Subject: ${subject}
IP: ${clientIP} IP: ${clientIP}
User Agent: ${userAgent}
${message} ${message}
============================== ==============================
@@ -275,11 +465,11 @@ ${message}
console.log('Message logged to file'); console.log('Message logged to file');
} catch (logError) { } catch (logError) {
console.error('Failed to log to file:', logError); console.error('Failed to log to file:', logError);
// Continue anyway - this is just a backup
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: true success: true,
message: 'Message sent successfully! We\'ll get back to you soon.'
}), { }), {
status: 200, status: 200,
headers: { headers: {

View File

@@ -101,7 +101,9 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
}); });
if (response.ok) { if (response.ok) {
formStatus.innerHTML = "<p>Message sent successfully! We'll get back to you soon.</p>"; const result = await response.json();
const successMessage = result.message || 'Message sent successfully! We\'ll get back to you soon.';
formStatus.innerHTML = `<p>${successMessage}</p>`;
formStatus.className = "form-status success"; formStatus.className = "form-status success";
contactForm.reset(); contactForm.reset();
} else if (response.status === 429) { } else if (response.status === 429) {
@@ -115,7 +117,7 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
result = { error: 'Could not send message. Please try again.' }; result = { error: 'Could not send message. Please try again.' };
} }
formStatus.innerHTML = `<p>Error: ${result.error || 'Could not send message. Please try again.'}</p>`; formStatus.innerHTML = `<p>${result.error || 'Could not send message. Please try again.'}</p>`;
formStatus.className = "form-status error"; formStatus.className = "form-status error";
} }
} catch (error) { } catch (error) {