feat: implement security enhancements (task 17)
- Add rate limiting for login, registration, and invitations - Implement CSRF protection with SameSite cookies and security headers - Configure secure session cookies with httpOnly and secure flags - Enhance input sanitization for XSS and injection prevention - Add comprehensive security tests (41 tests passing) - Document security enhancements in SECURITY_ENHANCEMENTS.md Requirements: 9.3, 9.4, 9.5
This commit is contained in:
189
lib/rate-limit.ts
Normal file
189
lib/rate-limit.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Rate limiting utilities for security
|
||||
* Implements in-memory rate limiting for login attempts and other sensitive operations
|
||||
*
|
||||
* Requirements: 9.3 - Rate limiting on authentication requests
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
// In-memory store for rate limiting
|
||||
// In production, consider using Redis or similar for distributed systems
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>()
|
||||
|
||||
// Cleanup interval to prevent memory leaks
|
||||
const CLEANUP_INTERVAL = 60 * 60 * 1000 // 1 hour
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
const entries = Array.from(rateLimitStore.entries())
|
||||
for (const [key, entry] of entries) {
|
||||
if (entry.resetAt < now) {
|
||||
rateLimitStore.delete(key)
|
||||
}
|
||||
}
|
||||
}, CLEANUP_INTERVAL)
|
||||
|
||||
export interface RateLimitConfig {
|
||||
/**
|
||||
* Maximum number of requests allowed within the window
|
||||
*/
|
||||
maxRequests: number
|
||||
|
||||
/**
|
||||
* Time window in milliseconds
|
||||
*/
|
||||
windowMs: number
|
||||
|
||||
/**
|
||||
* Optional identifier (e.g., IP address, user ID)
|
||||
* If not provided, uses a default key
|
||||
*/
|
||||
identifier?: string
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
/**
|
||||
* Whether the request is allowed
|
||||
*/
|
||||
allowed: boolean
|
||||
|
||||
/**
|
||||
* Number of requests remaining in the current window
|
||||
*/
|
||||
remaining: number
|
||||
|
||||
/**
|
||||
* Timestamp when the rate limit will reset
|
||||
*/
|
||||
resetAt: number
|
||||
|
||||
/**
|
||||
* Time in milliseconds until the rate limit resets
|
||||
*/
|
||||
retryAfter?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed based on rate limiting rules
|
||||
*
|
||||
* @param key - Unique identifier for the rate limit (e.g., "login:user@example.com" or "login:192.168.1.1")
|
||||
* @param config - Rate limit configuration
|
||||
* @returns Rate limit result indicating if request is allowed
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
config: RateLimitConfig
|
||||
): RateLimitResult {
|
||||
const now = Date.now()
|
||||
const entry = rateLimitStore.get(key)
|
||||
|
||||
// If no entry exists or the window has expired, create a new entry
|
||||
if (!entry || entry.resetAt < now) {
|
||||
const resetAt = now + config.windowMs
|
||||
rateLimitStore.set(key, {
|
||||
count: 1,
|
||||
resetAt,
|
||||
})
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - 1,
|
||||
resetAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if limit has been exceeded
|
||||
if (entry.count >= config.maxRequests) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.resetAt,
|
||||
retryAfter: entry.resetAt - now,
|
||||
}
|
||||
}
|
||||
|
||||
// Increment count and allow request
|
||||
entry.count++
|
||||
rateLimitStore.set(key, entry)
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - entry.count,
|
||||
resetAt: entry.resetAt,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a specific key
|
||||
* Useful for clearing rate limits after successful authentication
|
||||
*
|
||||
* @param key - Unique identifier for the rate limit
|
||||
*/
|
||||
export function resetRateLimit(key: string): void {
|
||||
rateLimitStore.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration for login attempts
|
||||
* Allows 5 login attempts per 15 minutes per email address
|
||||
*/
|
||||
export const LOGIN_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||
maxRequests: 5,
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration for registration attempts
|
||||
* Allows 3 registration attempts per hour per IP address
|
||||
*/
|
||||
export const REGISTER_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||
maxRequests: 3,
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration for password reset attempts
|
||||
* Allows 3 password reset attempts per hour per email
|
||||
*/
|
||||
export const PASSWORD_RESET_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||
maxRequests: 3,
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configuration for invitation creation
|
||||
* Allows 10 invitations per hour per user
|
||||
*/
|
||||
export const INVITATION_RATE_LIMIT: Omit<RateLimitConfig, 'identifier'> = {
|
||||
maxRequests: 10,
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client identifier from request headers
|
||||
* Uses X-Forwarded-For header if available, otherwise falls back to a default
|
||||
*
|
||||
* @param headers - Request headers
|
||||
* @returns Client identifier (IP address or default)
|
||||
*/
|
||||
export function getClientIdentifier(headers: Headers): string {
|
||||
// Try to get IP from X-Forwarded-For header (for proxied requests)
|
||||
const forwardedFor = headers.get('x-forwarded-for')
|
||||
if (forwardedFor) {
|
||||
// X-Forwarded-For can contain multiple IPs, use the first one
|
||||
return forwardedFor.split(',')[0].trim()
|
||||
}
|
||||
|
||||
// Try to get IP from X-Real-IP header
|
||||
const realIp = headers.get('x-real-ip')
|
||||
if (realIp) {
|
||||
return realIp
|
||||
}
|
||||
|
||||
// Fallback to a default identifier
|
||||
// In production, you might want to handle this differently
|
||||
return 'unknown'
|
||||
}
|
||||
Reference in New Issue
Block a user