diff --git a/src/lib/security/rate-limiter.test.ts b/src/lib/security/rate-limiter.test.ts new file mode 100644 index 0000000..65a92cb --- /dev/null +++ b/src/lib/security/rate-limiter.test.ts @@ -0,0 +1,58 @@ +import { RateLimiter, RateLimitResult } from './rate-limiter'; + +describe('Rate Limiting System', () => { + let rateLimiter: RateLimiter; + + beforeEach(() => { + rateLimiter = new RateLimiter(); + }); + + test('should allow request within limit', async () => { + const result = await rateLimiter.checkIP('192.168.1.1'); + expect(result.allowed).toBe(true); + expect(result.remaining).toBeGreaterThan(0); + }); + + test('should block request when limit exceeded', async () => { + const ip = '192.168.1.2'; + for (let i = 0; i < 10; i++) { + await rateLimiter.checkIP(ip); + } + const result = await rateLimiter.checkIP(ip); + expect(result.allowed).toBe(false); + }); + + test('should reset limit after window expires', async () => { + const ip = '192.168.1.3'; + for (let i = 0; i < 10; i++) { + await rateLimiter.checkIP(ip); + } + await rateLimiter.checkIP(ip); + + jest.useFakeTimers(); + jest.advanceTimersByTime(61 * 60 * 1000); + + const result = await rateLimiter.checkIP(ip); + expect(result.allowed).toBe(true); + jest.useRealTimers(); + }); + + test('should track email rate limits separately', async () => { + const email = 'test@example.com'; + for (let i = 0; i < 3; i++) { + await rateLimiter.checkEmail(email); + } + const result = await rateLimiter.checkEmail(email); + expect(result.allowed).toBe(false); + }); + + test('should enforce global rate limit', async () => { + const promises = []; + for (let i = 0; i < 51; i++) { + promises.push(rateLimiter.checkGlobal()); + } + const results = await Promise.all(promises); + const blockedCount = results.filter(r => !r.allowed).length; + expect(blockedCount).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/security/rate-limiter.ts b/src/lib/security/rate-limiter.ts new file mode 100644 index 0000000..f374079 --- /dev/null +++ b/src/lib/security/rate-limiter.ts @@ -0,0 +1,119 @@ +import { getSecurityConfig } from './config'; + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + resetTime: number; + limit: number; +} + +interface RequestRecord { + count: number; + windowStart: number; +} + +class InMemoryRateLimiter { + private ipRequests = new Map(); + private emailRequests = new Map(); + private globalRequests: RequestRecord = { count: 0, windowStart: Date.now() }; + + async checkIP(ip: string): Promise { + const config = getSecurityConfig(); + const now = Date.now(); + const windowMs = config.rateLimit.ip.windowMinutes * 60 * 1000; + + let record = this.ipRequests.get(ip); + if (!record || now - record.windowStart > windowMs) { + record = { count: 0, windowStart: now }; + this.ipRequests.set(ip, record); + } + + const allowed = record.count < config.rateLimit.ip.maxRequests; + if (allowed) { + record.count++; + } + + return { + allowed, + remaining: Math.max(0, config.rateLimit.ip.maxRequests - record.count), + resetTime: record.windowStart + windowMs, + limit: config.rateLimit.ip.maxRequests, + }; + } + + async checkEmail(email: string): Promise { + const config = getSecurityConfig(); + const now = Date.now(); + const windowMs = config.rateLimit.email.windowHours * 60 * 60 * 1000; + + let record = this.emailRequests.get(email); + if (!record || now - record.windowStart > windowMs) { + record = { count: 0, windowStart: now }; + this.emailRequests.set(email, record); + } + + const allowed = record.count < config.rateLimit.email.maxRequests; + if (allowed) { + record.count++; + } + + return { + allowed, + remaining: Math.max(0, config.rateLimit.email.maxRequests - record.count), + resetTime: record.windowStart + windowMs, + limit: config.rateLimit.email.maxRequests, + }; + } + + async checkGlobal(): Promise { + const config = getSecurityConfig(); + const now = Date.now(); + const windowMs = config.rateLimit.global.windowMinutes * 60 * 1000; + + if (now - this.globalRequests.windowStart > windowMs) { + this.globalRequests = { count: 0, windowStart: now }; + } + + const allowed = this.globalRequests.count < config.rateLimit.global.maxRequests; + if (allowed) { + this.globalRequests.count++; + } + + return { + allowed, + remaining: Math.max(0, config.rateLimit.global.maxRequests - this.globalRequests.count), + resetTime: this.globalRequests.windowStart + windowMs, + limit: config.rateLimit.global.maxRequests, + }; + } + + clear(): void { + this.ipRequests.clear(); + this.emailRequests.clear(); + this.globalRequests = { count: 0, windowStart: Date.now() }; + } +} + +export class RateLimiter { + private limiter: InMemoryRateLimiter; + + constructor() { + this.limiter = new InMemoryRateLimiter(); + } + + async checkIP(ip: string): Promise { + return this.limiter.checkIP(ip); + } + + async checkEmail(email: string): Promise { + return this.limiter.checkEmail(email); + } + + async checkGlobal(): Promise { + return this.limiter.checkGlobal(); + } + + clear(): void { + this.limiter.clear(); + } +}