feat: enhance contact form security and add animated hero
This commit is contained in:
parent
ea18dcdb8e
commit
8497cd819d
19 changed files with 320 additions and 112 deletions
|
|
@ -10,6 +10,8 @@ export default defineEventHandler(async (event) => {
|
|||
message: userMessage,
|
||||
phoneNumber: rawPhoneNumber,
|
||||
code,
|
||||
interactionData,
|
||||
website,
|
||||
} = await readBody(event);
|
||||
|
||||
let phoneNumber;
|
||||
|
|
@ -19,7 +21,32 @@ export default defineEventHandler(async (event) => {
|
|||
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||
}
|
||||
|
||||
// --- Input Validation ---
|
||||
if (website) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Spam detected.",
|
||||
});
|
||||
}
|
||||
|
||||
if (interactionData) {
|
||||
if (interactionData.timeSpent < 3000) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Submission too fast.",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
interactionData.mouseActivity === 0 &&
|
||||
interactionData.keyboardActivity === 0
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "No user interaction detected.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!name || !userMessage || !code) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
|
|
@ -27,12 +54,19 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Prevent abuse by checking rate limit before doing anything
|
||||
const nameRegex = /^[a-zA-Z\s'-]{2,50}$/;
|
||||
if (!nameRegex.test(name.trim())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Name contains invalid characters or format.",
|
||||
});
|
||||
}
|
||||
|
||||
if (isRateLimited(phoneNumber)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage:
|
||||
"You have already sent a message within the last week. Please try again later.",
|
||||
"You have reached the maximum of 3 messages per week. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -51,7 +85,50 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Server Configuration Check ---
|
||||
if (userMessage.trim().length < 10) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Message is too short.",
|
||||
});
|
||||
}
|
||||
|
||||
const spamKeywords = [
|
||||
"viagra",
|
||||
"casino",
|
||||
"lottery",
|
||||
"winner",
|
||||
"click here",
|
||||
"free money",
|
||||
"urgent",
|
||||
"limited time",
|
||||
];
|
||||
const hasSpamKeywords = spamKeywords.some((keyword) =>
|
||||
userMessage.toLowerCase().includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
if (hasSpamKeywords) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Message contains inappropriate content.",
|
||||
});
|
||||
}
|
||||
|
||||
if (/([a-zA-Z])\1{4,}/.test(userMessage)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Message contains excessive repeated characters.",
|
||||
});
|
||||
}
|
||||
|
||||
const uppercaseRatio =
|
||||
(userMessage.match(/[A-Z]/g) || []).length / userMessage.length;
|
||||
if (uppercaseRatio > 0.7 && userMessage.length > 10) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Message contains excessive uppercase text.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.myPhoneNumber || !config.superSecretSalt) {
|
||||
console.error(
|
||||
"Server is not fully configured. MY_PHONE_NUMBER and SUPER_SECRET_SALT are required.",
|
||||
|
|
@ -62,7 +139,6 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Verification ---
|
||||
const isVerified = verifyTOTP(phoneNumber, config.superSecretSalt, code);
|
||||
if (!isVerified) {
|
||||
throw createError({
|
||||
|
|
@ -72,7 +148,6 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Send Message ---
|
||||
try {
|
||||
const api = createSmsGatewayClient(config);
|
||||
const finalMessage = `New message from ${name} ( ${phoneNumber} ) via your portfolio:\n\n"${userMessage}"`;
|
||||
|
|
@ -83,7 +158,6 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
const state = await api.send(message);
|
||||
|
||||
// On success, record the submission time to start the rate-limiting period.
|
||||
recordSubmission(phoneNumber);
|
||||
|
||||
return { success: true, messageId: state.id };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { generateTOTP } from "../utils/totp";
|
||||
import { createSmsGatewayClient } from "../lib/sms-gateway";
|
||||
import { isRateLimited } from "../utils/rate-limiter.js";
|
||||
import {
|
||||
isRateLimited,
|
||||
isOtpRateLimited,
|
||||
recordOtpRequest,
|
||||
} from "../utils/rate-limiter.js";
|
||||
import { normalizeAndValidatePhoneNumber } from "../utils/phone-validator.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -11,16 +15,22 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
|
||||
} catch (error) {
|
||||
// The validator throws an error with a user-friendly message.
|
||||
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||
}
|
||||
|
||||
// Prevent abuse by checking rate limit before sending an SMS
|
||||
if (isOtpRateLimited(normalizedPhoneNumber)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage:
|
||||
"You have reached the maximum of 3 verification code requests per hour. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
if (isRateLimited(normalizedPhoneNumber)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
statusMessage:
|
||||
"You have already sent a message within the last week. Please try again later.",
|
||||
"You have reached the maximum of 3 messages per week. Please try again later.",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +54,9 @@ export default defineEventHandler(async (event) => {
|
|||
};
|
||||
|
||||
const state = await api.send(message);
|
||||
|
||||
recordOtpRequest(normalizedPhoneNumber);
|
||||
|
||||
return { success: true, messageId: state.id };
|
||||
} catch (error) {
|
||||
console.error("Failed to send OTP:", error);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
normalizedPhoneNumber = normalizeAndValidatePhoneNumber(rawPhoneNumber);
|
||||
} catch (error) {
|
||||
// The validator throws an error with a user-friendly message.
|
||||
throw createError({ statusCode: 400, statusMessage: error.message });
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +20,6 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Prevent abuse by checking rate limit before doing anything
|
||||
if (isRateLimited(normalizedPhoneNumber)) {
|
||||
throw createError({
|
||||
statusCode: 429,
|
||||
|
|
@ -30,10 +28,8 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Check for necessary server configuration.
|
||||
if (!config.superSecretSalt) {
|
||||
console.error("SUPER_SECRET_SALT is not configured on the server.");
|
||||
// This is an internal server error, so we don't expose details to the client.
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "A server configuration error occurred.",
|
||||
|
|
@ -47,10 +43,8 @@ export default defineEventHandler(async (event) => {
|
|||
);
|
||||
|
||||
if (isValid) {
|
||||
// In a stateful app, one might set a session cookie here.
|
||||
return { success: true };
|
||||
} else {
|
||||
// The code is incorrect or has expired.
|
||||
throw createError({
|
||||
statusCode: 401, // Unauthorized
|
||||
statusMessage: "Invalid or expired verification code.",
|
||||
|
|
|
|||
|
|
@ -28,8 +28,6 @@ export function createSmsGatewayClient(config) {
|
|||
console.error(
|
||||
"SMS Gateway service is not configured. Missing required environment variables for the gateway.",
|
||||
);
|
||||
// This indicates a critical server misconfiguration. The calling API endpoint
|
||||
// should handle this and return a generic 500 error to the client.
|
||||
throw new Error("Server is not configured for sending SMS.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,16 +18,13 @@ export function normalizeAndValidatePhoneNumber(rawPhoneNumber) {
|
|||
throw new Error("Phone number is required.");
|
||||
}
|
||||
|
||||
// 1. Strip all non-digit characters.
|
||||
const digitsOnly = rawPhoneNumber.replace(/\D/g, "");
|
||||
|
||||
// 2. If the number starts with a '1', remove it.
|
||||
let numberToValidate = digitsOnly;
|
||||
if (numberToValidate.startsWith("1")) {
|
||||
numberToValidate = numberToValidate.substring(1);
|
||||
}
|
||||
|
||||
// 3. Check if the resulting number is exactly 10 digits long.
|
||||
if (numberToValidate.length !== 10) {
|
||||
throw new Error("Please provide a valid 10-digit phone number.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,99 @@
|
|||
// A shared, in-memory store for tracking submission timestamps.
|
||||
const submissionTimestamps = new Map();
|
||||
|
||||
// The rate-limiting period (1 week in milliseconds).
|
||||
const otpRequestTimestamps = new Map();
|
||||
|
||||
const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
|
||||
|
||||
const MAX_OTP_REQUESTS_PER_HOUR = 3;
|
||||
|
||||
const MAX_MESSAGES_PER_WEEK = 3;
|
||||
|
||||
/**
|
||||
* Checks if a given phone number is currently rate-limited.
|
||||
* A phone number is considered rate-limited if a successful submission
|
||||
* was recorded within the last week.
|
||||
* Checks if a given phone number is currently rate-limited for message submissions.
|
||||
* A phone number is considered rate-limited if it has made 3 or more message submissions
|
||||
* within the last week.
|
||||
*
|
||||
* @param {string} phoneNumber The phone number to check.
|
||||
* @returns {boolean} True if the number is rate-limited, false otherwise.
|
||||
*/
|
||||
export function isRateLimited(phoneNumber) {
|
||||
const lastSubmissionTime = submissionTimestamps.get(phoneNumber);
|
||||
if (!lastSubmissionTime) {
|
||||
return false; // Not in the map, so not rate-limited.
|
||||
const submissionTimestampsArray = submissionTimestamps.get(phoneNumber);
|
||||
if (!submissionTimestampsArray || submissionTimestampsArray.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the time elapsed since the last submission is less than one week.
|
||||
return Date.now() - lastSubmissionTime < ONE_WEEK_IN_MS;
|
||||
const now = Date.now();
|
||||
const recentSubmissions = submissionTimestampsArray.filter(
|
||||
(timestamp) => now - timestamp < ONE_WEEK_IN_MS,
|
||||
);
|
||||
|
||||
if (recentSubmissions.length !== submissionTimestampsArray.length) {
|
||||
submissionTimestamps.set(phoneNumber, recentSubmissions);
|
||||
}
|
||||
|
||||
return recentSubmissions.length >= MAX_MESSAGES_PER_WEEK;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a successful submission for a given phone number by setting
|
||||
* the current timestamp. This will start the 1-week rate-limiting period.
|
||||
* Records a successful submission for a given phone number by adding
|
||||
* the current timestamp to the submission history.
|
||||
*
|
||||
* @param {string} phoneNumber The phone number to record the submission for.
|
||||
*/
|
||||
export function recordSubmission(phoneNumber) {
|
||||
submissionTimestamps.set(phoneNumber, Date.now());
|
||||
const now = Date.now();
|
||||
const existingSubmissions = submissionTimestamps.get(phoneNumber) || [];
|
||||
|
||||
const recentSubmissions = existingSubmissions.filter(
|
||||
(timestamp) => now - timestamp < ONE_WEEK_IN_MS,
|
||||
);
|
||||
recentSubmissions.push(now);
|
||||
|
||||
submissionTimestamps.set(phoneNumber, recentSubmissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given phone number is currently rate-limited for OTP requests.
|
||||
* A phone number is considered rate-limited if it has made 3 or more OTP requests
|
||||
* within the last hour.
|
||||
*
|
||||
* @param {string} phoneNumber The phone number to check.
|
||||
* @returns {boolean} True if the number is rate-limited for OTP, false otherwise.
|
||||
*/
|
||||
export function isOtpRateLimited(phoneNumber) {
|
||||
const requestTimestamps = otpRequestTimestamps.get(phoneNumber);
|
||||
if (!requestTimestamps || requestTimestamps.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const recentRequests = requestTimestamps.filter(
|
||||
(timestamp) => now - timestamp < ONE_HOUR_IN_MS,
|
||||
);
|
||||
|
||||
if (recentRequests.length !== requestTimestamps.length) {
|
||||
otpRequestTimestamps.set(phoneNumber, recentRequests);
|
||||
}
|
||||
|
||||
return recentRequests.length >= MAX_OTP_REQUESTS_PER_HOUR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an OTP request for a given phone number by adding
|
||||
* the current timestamp to the request history.
|
||||
*
|
||||
* @param {string} phoneNumber The phone number to record the OTP request for.
|
||||
*/
|
||||
export function recordOtpRequest(phoneNumber) {
|
||||
const now = Date.now();
|
||||
const existingRequests = otpRequestTimestamps.get(phoneNumber) || [];
|
||||
|
||||
const recentRequests = existingRequests.filter(
|
||||
(timestamp) => now - timestamp < ONE_HOUR_IN_MS,
|
||||
);
|
||||
recentRequests.push(now);
|
||||
|
||||
otpRequestTimestamps.set(phoneNumber, recentRequests);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { authenticator } from "otplib";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
// These settings must be consistent between generation and verification.
|
||||
authenticator.options = {
|
||||
step: 60, // OTP is valid for 1 minute per window
|
||||
window: [5, 1], // Allow tokens from 5 previous and 1 future time-steps.
|
||||
step: 60,
|
||||
window: [5, 1],
|
||||
digits: 6,
|
||||
};
|
||||
|
||||
|
|
@ -46,8 +45,6 @@ export function generateTOTP(phoneNumber, salt) {
|
|||
*/
|
||||
export function verifyTOTP(phoneNumber, salt, token) {
|
||||
const userSecret = getUserSecret(phoneNumber, salt);
|
||||
// The `verify` method checks the token against the current and adjacent
|
||||
// time-steps, as configured in the options.
|
||||
return authenticator.verify({ token, secret: userSecret });
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue