diff --git a/logs/contact-messages.log b/logs/contact-messages.log index ce40d6d..96a76f7 100644 --- a/logs/contact-messages.log +++ b/logs/contact-messages.log @@ -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. ============================== + +=== 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. +============================== diff --git a/src/pages/api/send-message.js b/src/pages/api/send-message.js index 2ebd00f..cd882af 100644 --- a/src/pages/api/send-message.js +++ b/src/pages/api/send-message.js @@ -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: { diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 4a03622..62a6c44 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -101,7 +101,9 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL; }); if (response.ok) { - formStatus.innerHTML = "

Message sent successfully! We'll get back to you soon.

"; + const result = await response.json(); + const successMessage = result.message || 'Message sent successfully! We\'ll get back to you soon.'; + formStatus.innerHTML = `

${successMessage}

`; 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 = `

Error: ${result.error || 'Could not send message. Please try again.'}

`; + formStatus.innerHTML = `

${result.error || 'Could not send message. Please try again.'}

`; formStatus.className = "form-status error"; } } catch (error) {