improved on contact form security
This commit is contained in:
@@ -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.
|
||||
==============================
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -101,7 +101,9 @@ const ntfyUrl = import.meta.env.PUBLIC_NTFY_URL;
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
formStatus.innerHTML = "<p>Message sent successfully! We'll get back to you soon.</p>";
|
||||
const result = await response.json();
|
||||
const successMessage = result.message || 'Message sent successfully! We\'ll get back to you soon.';
|
||||
formStatus.innerHTML = `<p>${successMessage}</p>`;
|
||||
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 = `<p>Error: ${result.error || 'Could not send message. Please try again.'}</p>`;
|
||||
formStatus.innerHTML = `<p>${result.error || 'Could not send message. Please try again.'}</p>`;
|
||||
formStatus.className = "form-status error";
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user