contact form security updates
This commit is contained in:
@@ -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.
|
||||
==============================
|
||||
|
||||
@@ -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.'
|
||||
}), {
|
||||
|
||||
@@ -32,6 +32,12 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
<input type="tel" id="phone" name="phone">
|
||||
</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">
|
||||
<label for="subject">Subject</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
@@ -48,6 +54,18 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
</div>
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const contactForm = document.getElementById('contactForm');
|
||||
@@ -61,6 +79,7 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
const phone = document.getElementById('phone').value;
|
||||
const subject = document.getElementById('subject').value;
|
||||
const message = document.getElementById('message').value;
|
||||
const website = document.getElementById('website').value; // honeypot
|
||||
|
||||
formStatus.innerHTML = "<p>Sending your message...</p>";
|
||||
formStatus.className = "form-status sending";
|
||||
@@ -76,7 +95,8 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
email,
|
||||
phone,
|
||||
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.className = "form-status success";
|
||||
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 {
|
||||
let result;
|
||||
try {
|
||||
@@ -96,6 +119,7 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
formStatus.className = "form-status error";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error);
|
||||
formStatus.innerHTML = `<p>Error: Could not send message. Please try again later.</p>`;
|
||||
formStatus.className = "form-status error";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user