From 5b18bf8d537c4ce745e66d18e2d0d7a966599641 Mon Sep 17 00:00:00 2001 From: Stephan Date: Sat, 24 May 2025 14:28:44 -0700 Subject: [PATCH] contact form security updates --- logs/contact-messages.log | 11 +++ src/pages/api/send-message.js | 166 +++++++++++++++++++++++++++++++++- src/pages/contact.astro | 26 +++++- 3 files changed, 199 insertions(+), 4 deletions(-) diff --git a/logs/contact-messages.log b/logs/contact-messages.log index 26d2ec8..ce40d6d 100644 --- a/logs/contact-messages.log +++ b/logs/contact-messages.log @@ -28,3 +28,14 @@ Subject: sdafsdf sdfsdfsdf ============================== + +=== New Message 2025-05-24T21:19:28.152Z === +From: asdasd (asdasd@asdasd.com) +Phone: 123111123132 +Subject: asdasd +IP: unknown + +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. +============================== diff --git a/src/pages/api/send-message.js b/src/pages/api/send-message.js index 0a177b8..2ebd00f 100644 --- a/src/pages/api/send-message.js +++ b/src/pages/api/send-message.js @@ -1,13 +1,124 @@ // src/pages/api/send-message.js 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 MAX_REQUESTS = 3; // Max 3 submissions per 15 minutes per IP + +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'); + + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + if (realIP) { + return realIP; + } + if (cfIP) { + return cfIP; + } + + // Fallback + return 'unknown'; +} + +function isRateLimited(ip) { + const now = Date.now(); + const userRequests = rateLimitMap.get(ip) || []; + + // Clean old requests + const recentRequests = userRequests.filter(time => now - time < RATE_LIMIT_WINDOW); + + if (recentRequests.length >= MAX_REQUESTS) { + return true; + } + + // Add current request + recentRequests.push(now); + rateLimitMap.set(ip, recentRequests); + + return false; +} + +// Simple honeypot and spam detection +function detectSpam(data) { + const { name, email, subject, message, website } = data; + + // Check for honeypot field - if filled, it's likely a bot + if (website && website.trim() !== '') { + return "Honeypot triggered"; + } + + // Check for suspicious patterns + 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, + ]; + + const fullText = `${name} ${email} ${subject} ${message}`.toLowerCase(); + + for (const pattern of suspiciousPatterns) { + if (pattern.test(fullText)) { + return "Suspicious content detected"; + } + } + + // 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) + if (/(.)\1{10,}/.test(message)) { + return "Suspicious character repetition"; + } + + return null; +} + export const POST = async ({ request }) => { try { + // Get client IP + const clientIP = getRealIP(request); + console.log(`Contact form submission from IP: ${clientIP}`); + + // Check rate limiting + if (isRateLimited(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 + } + }); + } + // Parse the request body let data; try { data = await request.json(); + console.log('Received form data:', { ...data, website: data.website ? '[FILLED]' : '[EMPTY]' }); } catch (parseError) { + console.error('Failed to parse request body:', parseError); return new Response(JSON.stringify({ error: 'Invalid request format' }), { @@ -22,8 +133,9 @@ export const POST = async ({ request }) => { // Validate the form data if (!name || !email || !subject || !message) { + console.log('Missing required fields'); return new Response(JSON.stringify({ - error: 'All fields are required' + error: 'All required fields must be filled' }), { status: 400, headers: { @@ -32,11 +144,46 @@ export const POST = async ({ request }) => { }); } + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + console.log('Invalid email format:', email); + return new Response(JSON.stringify({ + error: 'Please provide a valid email address' + }), { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + // 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}`); + + // Return success to avoid revealing spam detection + return new Response(JSON.stringify({ + success: true + }), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } + // Get ntfy configuration from environment variables const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL; const ntfyToken = import.meta.env.PUBLIC_NTFY_TOKEN; + console.log('ntfy URL configured:', !!ntfyUrl); + console.log('ntfy Token configured:', !!ntfyToken); + if (!ntfyUrl) { + console.error('ntfy URL not configured'); return new Response(JSON.stringify({ error: 'Server configuration error' }), { @@ -47,7 +194,7 @@ export const POST = async ({ request }) => { }); } - // Format the message in Markdown similar to your Proxmox template + // Format the message in Markdown const messageBody = `** Website Contact Form ** ${subject} @@ -59,7 +206,8 @@ ${message} **Time:** ${new Date().toISOString()} **Name:** ${name} **Email:** ${email} -**Phone:** ${phone || 'Not provided'}`; +**Phone:** ${phone || 'Not provided'} +**IP:** ${clientIP}`; // Prepare headers with Bearer token authentication const headers = { @@ -74,6 +222,8 @@ ${message} headers['Authorization'] = `Bearer ${ntfyToken}`; } + console.log('Sending to ntfy...'); + // Send to ntfy const response = await fetch(ntfyUrl, { method: 'POST', @@ -81,7 +231,11 @@ ${message} body: messageBody }); + console.log('ntfy response status:', response.status); + if (!response.ok) { + const errorText = await response.text(); + console.error('ntfy error response:', errorText); return new Response(JSON.stringify({ error: 'Failed to send message. Please try again later.' }), { @@ -92,6 +246,8 @@ ${message} }); } + console.log('Message sent successfully to ntfy'); + // Add file logging as a backup try { const fs = await import('fs'); @@ -109,13 +265,16 @@ ${message} From: ${name} (${email}) Phone: ${phone || 'Not provided'} Subject: ${subject} +IP: ${clientIP} ${message} ============================== `; fs.default.appendFileSync(logFile, logMessage); + console.log('Message logged to file'); } catch (logError) { + console.error('Failed to log to file:', logError); // Continue anyway - this is just a backup } @@ -128,6 +287,7 @@ ${message} } }); } catch (error) { + console.error('Unexpected error in contact form:', error); return new Response(JSON.stringify({ error: 'Server error occurred. Please try again later.' }), { diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 374dfef..4a03622 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -32,6 +32,12 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL; + +
+ + +
+
@@ -48,6 +54,18 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
+ +