contact form security updates

This commit is contained in:
2025-05-24 14:28:44 -07:00
parent e131272a9a
commit 5b18bf8d53
3 changed files with 199 additions and 4 deletions

View File

@@ -28,3 +28,14 @@ Subject: sdafsdf
sdfsdfsdf 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.
==============================

View File

@@ -1,13 +1,124 @@
// src/pages/api/send-message.js // src/pages/api/send-message.js
export const prerender = false; 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 }) => { export const POST = async ({ request }) => {
try { 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 // Parse the request body
let data; let data;
try { try {
data = await request.json(); data = await request.json();
console.log('Received form data:', { ...data, website: data.website ? '[FILLED]' : '[EMPTY]' });
} catch (parseError) { } catch (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'
}), { }), {
@@ -22,8 +133,9 @@ export const POST = async ({ request }) => {
// Validate the form data // Validate the form data
if (!name || !email || !subject || !message) { if (!name || !email || !subject || !message) {
console.log('Missing required fields');
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'All fields are required' error: 'All required fields must be filled'
}), { }), {
status: 400, status: 400,
headers: { 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 // Get ntfy configuration from environment variables
const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL; const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
const ntfyToken = import.meta.env.PUBLIC_NTFY_TOKEN; const ntfyToken = import.meta.env.PUBLIC_NTFY_TOKEN;
console.log('ntfy URL configured:', !!ntfyUrl);
console.log('ntfy Token configured:', !!ntfyToken);
if (!ntfyUrl) { if (!ntfyUrl) {
console.error('ntfy URL not configured');
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Server configuration error' 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 ** const messageBody = `** Website Contact Form **
${subject} ${subject}
@@ -59,7 +206,8 @@ ${message}
**Time:** ${new Date().toISOString()} **Time:** ${new Date().toISOString()}
**Name:** ${name} **Name:** ${name}
**Email:** ${email} **Email:** ${email}
**Phone:** ${phone || 'Not provided'}`; **Phone:** ${phone || 'Not provided'}
**IP:** ${clientIP}`;
// Prepare headers with Bearer token authentication // Prepare headers with Bearer token authentication
const headers = { const headers = {
@@ -74,6 +222,8 @@ ${message}
headers['Authorization'] = `Bearer ${ntfyToken}`; headers['Authorization'] = `Bearer ${ntfyToken}`;
} }
console.log('Sending to ntfy...');
// Send to ntfy // Send to ntfy
const response = await fetch(ntfyUrl, { const response = await fetch(ntfyUrl, {
method: 'POST', method: 'POST',
@@ -81,7 +231,11 @@ ${message}
body: messageBody body: messageBody
}); });
console.log('ntfy response status:', response.status);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text();
console.error('ntfy error response:', errorText);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Failed to send message. Please try again later.' 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 // Add file logging as a backup
try { try {
const fs = await import('fs'); const fs = await import('fs');
@@ -109,13 +265,16 @@ ${message}
From: ${name} (${email}) From: ${name} (${email})
Phone: ${phone || 'Not provided'} Phone: ${phone || 'Not provided'}
Subject: ${subject} Subject: ${subject}
IP: ${clientIP}
${message} ${message}
============================== ==============================
`; `;
fs.default.appendFileSync(logFile, logMessage); fs.default.appendFileSync(logFile, logMessage);
console.log('Message logged to file');
} catch (logError) { } catch (logError) {
console.error('Failed to log to file:', logError);
// Continue anyway - this is just a backup // Continue anyway - this is just a backup
} }
@@ -128,6 +287,7 @@ ${message}
} }
}); });
} catch (error) { } catch (error) {
console.error('Unexpected error in contact form:', error);
return new Response(JSON.stringify({ return new Response(JSON.stringify({
error: 'Server error occurred. Please try again later.' error: 'Server error occurred. Please try again later.'
}), { }), {

View File

@@ -32,6 +32,12 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
<input type="tel" id="phone" name="phone"> <input type="tel" id="phone" name="phone">
</div> </div>
<!-- Honeypot field - bots will fill this, humans won't see it -->
<div class="honeypot">
<label for="website">Website (leave blank)</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="form-group"> <div class="form-group">
<label for="subject">Subject</label> <label for="subject">Subject</label>
<input type="text" id="subject" name="subject" required> <input type="text" id="subject" name="subject" required>
@@ -48,6 +54,18 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
</div> </div>
</Layout> </Layout>
<style>
.honeypot {
position: absolute !important;
left: -5000px !important;
top: -5000px !important;
height: 0 !important;
width: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
}
</style>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const contactForm = document.getElementById('contactForm'); const contactForm = document.getElementById('contactForm');
@@ -61,6 +79,7 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
const phone = document.getElementById('phone').value; const phone = document.getElementById('phone').value;
const subject = document.getElementById('subject').value; const subject = document.getElementById('subject').value;
const message = document.getElementById('message').value; const message = document.getElementById('message').value;
const website = document.getElementById('website').value; // honeypot
formStatus.innerHTML = "<p>Sending your message...</p>"; formStatus.innerHTML = "<p>Sending your message...</p>";
formStatus.className = "form-status sending"; formStatus.className = "form-status sending";
@@ -76,7 +95,8 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
email, email,
phone, phone,
subject, subject,
message message,
website // Include honeypot in submission
}) })
}); });
@@ -84,6 +104,9 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
formStatus.innerHTML = "<p>Message sent successfully! We'll get back to you soon.</p>"; formStatus.innerHTML = "<p>Message sent successfully! We'll get back to you soon.</p>";
formStatus.className = "form-status success"; formStatus.className = "form-status success";
contactForm.reset(); contactForm.reset();
} else if (response.status === 429) {
formStatus.innerHTML = "<p>Please wait a few minutes before submitting another message.</p>";
formStatus.className = "form-status error";
} else { } else {
let result; let result;
try { try {
@@ -96,6 +119,7 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
formStatus.className = "form-status error"; formStatus.className = "form-status error";
} }
} catch (error) { } catch (error) {
console.error('Form submission error:', error);
formStatus.innerHTML = `<p>Error: Could not send message. Please try again later.</p>`; formStatus.innerHTML = `<p>Error: Could not send message. Please try again later.</p>`;
formStatus.className = "form-status error"; formStatus.className = "form-status error";
} }