contact form security updates
This commit is contained in:
@@ -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.
|
||||||
|
==============================
|
||||||
|
|||||||
@@ -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.'
|
||||||
}), {
|
}), {
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user