- ICS-Dateianhänge in Bestätigungsmails mit Europe/Berlin Zeitzone - Rate-Limiting: IP-basiert (5/10min) und E-Mail-basiert (3/1h) - Mehrschichtige E-Mail-Validierung mit Rapid Email Validator API - Disposable Email Detection (blockiert Wegwerf-Adressen) - MX Record Verification - Domain Verification - Typo-Erkennung mit Vorschlägen - Zod-Schema-Validierung für Name, E-Mail und Telefonnummer - Dokumentation für Rate-Limiting und E-Mail-Validierung - README mit neuen Features aktualisiert - Backlog aktualisiert
163 lines
3.8 KiB
TypeScript
163 lines
3.8 KiB
TypeScript
// Simple in-memory rate limiter for IP and email-based requests
|
|
// For production with multiple instances, consider using Redis
|
|
|
|
type RateLimitEntry = {
|
|
count: number;
|
|
resetAt: number; // Unix timestamp in ms
|
|
};
|
|
|
|
const rateLimitStore = new Map<string, RateLimitEntry>();
|
|
|
|
// Cleanup old entries every 10 minutes to prevent memory leaks
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of rateLimitStore.entries()) {
|
|
if (entry.resetAt < now) {
|
|
rateLimitStore.delete(key);
|
|
}
|
|
}
|
|
}, 10 * 60 * 1000);
|
|
|
|
export type RateLimitConfig = {
|
|
maxRequests: number;
|
|
windowMs: number; // Time window in milliseconds
|
|
};
|
|
|
|
export type RateLimitResult = {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: number;
|
|
retryAfterSeconds?: number;
|
|
};
|
|
|
|
/**
|
|
* Check if a request is allowed based on rate limiting
|
|
* @param key - Unique identifier (IP, email, or combination)
|
|
* @param config - Rate limit configuration
|
|
* @returns RateLimitResult with allow status and metadata
|
|
*/
|
|
export function checkRateLimit(
|
|
key: string,
|
|
config: RateLimitConfig
|
|
): RateLimitResult {
|
|
const now = Date.now();
|
|
const entry = rateLimitStore.get(key);
|
|
|
|
// No existing entry or window expired - allow and create new entry
|
|
if (!entry || entry.resetAt < now) {
|
|
rateLimitStore.set(key, {
|
|
count: 1,
|
|
resetAt: now + config.windowMs,
|
|
});
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining: config.maxRequests - 1,
|
|
resetAt: now + config.windowMs,
|
|
};
|
|
}
|
|
|
|
// Existing entry within window
|
|
if (entry.count >= config.maxRequests) {
|
|
// Rate limit exceeded
|
|
const retryAfterSeconds = Math.ceil((entry.resetAt - now) / 1000);
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetAt: entry.resetAt,
|
|
retryAfterSeconds,
|
|
};
|
|
}
|
|
|
|
// Increment count and allow
|
|
entry.count++;
|
|
rateLimitStore.set(key, entry);
|
|
|
|
return {
|
|
allowed: true,
|
|
remaining: config.maxRequests - entry.count,
|
|
resetAt: entry.resetAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check rate limit for booking creation
|
|
* Applies multiple checks: per IP and per email
|
|
*/
|
|
export function checkBookingRateLimit(params: {
|
|
ip?: string;
|
|
email: string;
|
|
}): RateLimitResult {
|
|
const { ip, email } = params;
|
|
|
|
// Config: max 3 bookings per email per hour
|
|
const emailConfig: RateLimitConfig = {
|
|
maxRequests: 3,
|
|
windowMs: 60 * 60 * 1000, // 1 hour
|
|
};
|
|
|
|
// Config: max 5 bookings per IP per 10 minutes
|
|
const ipConfig: RateLimitConfig = {
|
|
maxRequests: 5,
|
|
windowMs: 10 * 60 * 1000, // 10 minutes
|
|
};
|
|
|
|
// Check email rate limit
|
|
const emailKey = `booking:email:${email.toLowerCase()}`;
|
|
const emailResult = checkRateLimit(emailKey, emailConfig);
|
|
|
|
if (!emailResult.allowed) {
|
|
return {
|
|
...emailResult,
|
|
allowed: false,
|
|
};
|
|
}
|
|
|
|
// Check IP rate limit (if IP is available)
|
|
if (ip) {
|
|
const ipKey = `booking:ip:${ip}`;
|
|
const ipResult = checkRateLimit(ipKey, ipConfig);
|
|
|
|
if (!ipResult.allowed) {
|
|
return {
|
|
...ipResult,
|
|
allowed: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Both checks passed
|
|
return {
|
|
allowed: true,
|
|
remaining: Math.min(emailResult.remaining, ip ? Infinity : emailResult.remaining),
|
|
resetAt: emailResult.resetAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get client IP from various headers (for proxy/load balancer support)
|
|
*/
|
|
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
|
|
// Check common proxy headers
|
|
const forwardedFor = headers['x-forwarded-for'];
|
|
if (forwardedFor) {
|
|
// x-forwarded-for can contain multiple IPs, take the first one
|
|
return forwardedFor.split(',')[0].trim();
|
|
}
|
|
|
|
const realIP = headers['x-real-ip'];
|
|
if (realIP) {
|
|
return realIP;
|
|
}
|
|
|
|
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
|
if (cfConnectingIP) {
|
|
return cfConnectingIP;
|
|
}
|
|
|
|
// No IP found
|
|
return undefined;
|
|
}
|
|
|
|
|