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

@@ -2,13 +2,54 @@
export const prerender = false;
// Simple in-memory rate limiting (for single server)
// For production with multiple servers, consider Redis or database storage
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
// 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) {
// Get real IP from various headers (useful behind nginx proxy)
const forwarded = request.headers.get('x-forwarded-for');
const realIP = request.headers.get('x-real-ip');
const cfIP = request.headers.get('cf-connecting-ip');
@@ -23,91 +64,213 @@ function getRealIP(request) {
return cfIP;
}
// Fallback
return 'unknown';
}
function isRateLimited(ip) {
const now = Date.now();
const userRequests = rateLimitMap.get(ip) || [];
const userData = rateLimitMap.get(ip) || { requests: [], blockedUntil: null };
// Clean old requests
const recentRequests = userRequests.filter(time => now - time < RATE_LIMIT_WINDOW);
if (recentRequests.length >= MAX_REQUESTS) {
// Check if IP is currently blocked
if (userData.blockedUntil && now < userData.blockedUntil) {
const remainingTime = Math.ceil((userData.blockedUntil - now) / 1000 / 60); // minutes
logSuspiciousActivity(ip, 'BLOCKED_IP_ATTEMPT', {
remainingBlockTime: remainingTime + ' minutes',
blockedUntil: new Date(userData.blockedUntil).toISOString()
});
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);
rateLimitMap.set(ip, recentRequests);
userData.requests = recentRequests;
userData.blockedUntil = null; // Clear any previous block
rateLimitMap.set(ip, userData);
return false;
}
// Simple honeypot and spam detection
function detectSpam(data) {
const { name, email, subject, message, website } = data;
function validateMessage(data, ip, userAgent) {
const { name, email, subject, message } = data;
// Check for honeypot field - if filled, it's likely a bot
if (website && website.trim() !== '') {
return "Honeypot triggered";
// Check message length first (legitimate validation errors)
if (message.length < 10) {
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 = [
/viagra|cialis|casino|poker|loan|mortgage|bitcoin|crypto/i,
/\b(click here|buy now|limited time|act now)\b/i,
/http[s]?:\/\/.*\.(tk|ml|ga|cf|bit\.ly)/i, // Suspicious domains
/\b(seo|backlink|link building|rank higher)\b/i,
{ pattern: /viagra|cialis|casino|poker|loan|mortgage|bitcoin|crypto/i, name: 'SPAM_KEYWORDS' },
{ pattern: /\b(click here|buy now|limited time|act now)\b/i, name: 'MARKETING_SPAM' },
{ pattern: /http[s]?:\/\/.*\.(tk|ml|ga|cf|bit\.ly)/i, name: 'SUSPICIOUS_DOMAINS' },
{ pattern: /\b(seo|backlink|link building|rank higher)\b/i, name: 'SEO_SPAM' },
];
const fullText = `${name} ${email} ${subject} ${message}`.toLowerCase();
for (const pattern of suspiciousPatterns) {
for (const { pattern, name: patternName } of suspiciousPatterns) {
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
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)
// Check for repeated characters (likely spam - hide detection)
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 }) => {
try {
// Get client IP
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
if (isRateLimited(clientIP)) {
console.log(`Rate limit exceeded for IP: ${clientIP}`);
console.log(`🚫 Rate limit exceeded for IP: ${clientIP}`);
return new Response(JSON.stringify({
error: 'Too many requests. Please wait before submitting again.'
}), {
status: 429,
headers: {
'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();
console.log('Received form data:', { ...data, website: data.website ? '[FILLED]' : '[EMPTY]' });
} catch (parseError) {
logSuspiciousActivity(clientIP, 'INVALID_JSON', { userAgent });
console.error('Failed to parse request body:', parseError);
return new Response(JSON.stringify({
error: 'Invalid request format'
@@ -131,8 +295,12 @@ export const POST = async ({ request }) => {
const { name, email, phone, subject, message } = data;
// Validate the form data
// Validate required fields
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');
return new Response(JSON.stringify({
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@]+$/;
if (!emailRegex.test(email)) {
logSuspiciousActivity(clientIP, 'INVALID_EMAIL', {
userAgent,
formData: { name, email, subject }
});
console.log('Invalid email format:', email);
return new Response(JSON.stringify({
error: 'Please provide a valid email address'
@@ -158,15 +330,31 @@ export const POST = async ({ request }) => {
});
}
// Spam detection
const spamReason = detectSpam(data);
if (spamReason) {
// Log spam attempt but don't reveal detection to user
console.log(`Spam detected from ${clientIP}: ${spamReason}`);
// First check for validation errors
const validationError = validateMessage(data, clientIP, userAgent);
if (validationError) {
const { message: responseMessage, logReason } = validationError;
// Return success to avoid revealing spam detection
console.log(`❌ Validation error from ${clientIP}: ${logReason}`);
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,
headers: {
@@ -207,7 +395,8 @@ ${message}
**Name:** ${name}
**Email:** ${email}
**Phone:** ${phone || 'Not provided'}
**IP:** ${clientIP}`;
**IP:** ${clientIP}
**User Agent:** ${userAgent}`;
// Prepare headers with Bearer token authentication
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 {
const fs = await import('fs');
const path = await import('path');
@@ -266,6 +455,7 @@ From: ${name} (${email})
Phone: ${phone || 'Not provided'}
Subject: ${subject}
IP: ${clientIP}
User Agent: ${userAgent}
${message}
==============================
@@ -275,11 +465,11 @@ ${message}
console.log('Message logged to file');
} catch (logError) {
console.error('Failed to log to file:', logError);
// Continue anyway - this is just a backup
}
return new Response(JSON.stringify({
success: true
success: true,
message: 'Message sent successfully! We\'ll get back to you soon.'
}), {
status: 200,
headers: {

View File

@@ -101,7 +101,9 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
});
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";
contactForm.reset();
} 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.' };
}
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";
}
} catch (error) {