mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
- Added rate limiting to the authentication middleware to prevent brute-force attacks. - Introduced a secure comparison function to mitigate timing attacks during API key validation. - Created a new rate limiter class to track failed authentication attempts and block requests after exceeding the maximum allowed failures. - Updated the authentication middleware to handle rate limiting and secure key comparison. - Enhanced error handling for rate-limited requests, providing appropriate responses to clients.
250 lines
7.6 KiB
TypeScript
250 lines
7.6 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { RateLimiter } from '../../../src/lib/rate-limiter.js';
|
|
import type { Request } from 'express';
|
|
|
|
describe('RateLimiter', () => {
|
|
let rateLimiter: RateLimiter;
|
|
|
|
beforeEach(() => {
|
|
rateLimiter = new RateLimiter({
|
|
maxAttempts: 3,
|
|
windowMs: 60000, // 1 minute
|
|
blockDurationMs: 60000, // 1 minute
|
|
});
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('getClientIp', () => {
|
|
it('should extract IP from x-forwarded-for header', () => {
|
|
const req = {
|
|
headers: { 'x-forwarded-for': '192.168.1.100' },
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
} as unknown as Request;
|
|
|
|
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
|
|
});
|
|
|
|
it('should use first IP from x-forwarded-for with multiple IPs', () => {
|
|
const req = {
|
|
headers: { 'x-forwarded-for': '192.168.1.100, 10.0.0.1, 172.16.0.1' },
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
} as unknown as Request;
|
|
|
|
expect(rateLimiter.getClientIp(req)).toBe('192.168.1.100');
|
|
});
|
|
|
|
it('should fall back to socket remoteAddress when no x-forwarded-for', () => {
|
|
const req = {
|
|
headers: {},
|
|
socket: { remoteAddress: '127.0.0.1' },
|
|
} as unknown as Request;
|
|
|
|
expect(rateLimiter.getClientIp(req)).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('should return "unknown" when no IP can be determined', () => {
|
|
const req = {
|
|
headers: {},
|
|
socket: { remoteAddress: undefined },
|
|
} as unknown as Request;
|
|
|
|
expect(rateLimiter.getClientIp(req)).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
describe('isBlocked', () => {
|
|
it('should return false for unknown keys', () => {
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
});
|
|
|
|
it('should return false after recording fewer failures than max', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
});
|
|
|
|
it('should return true after reaching max failures', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
});
|
|
|
|
it('should return false after block expires', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
|
|
// Advance time past block duration
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('recordFailure', () => {
|
|
it('should return false when not yet blocked', () => {
|
|
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
});
|
|
|
|
it('should return true when threshold is reached', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(true);
|
|
});
|
|
|
|
it('should reset counter after window expires', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
// Advance time past window
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
// Should start fresh
|
|
expect(rateLimiter.recordFailure('192.168.1.1')).toBe(false);
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
});
|
|
|
|
it('should track different IPs independently', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
rateLimiter.recordFailure('192.168.1.2');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
expect(rateLimiter.isBlocked('192.168.1.2')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('reset', () => {
|
|
it('should clear record for a key', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
rateLimiter.reset('192.168.1.1');
|
|
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
});
|
|
|
|
it('should clear blocked status', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(true);
|
|
|
|
rateLimiter.reset('192.168.1.1');
|
|
|
|
expect(rateLimiter.isBlocked('192.168.1.1')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('getAttemptsRemaining', () => {
|
|
it('should return max attempts for unknown key', () => {
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
});
|
|
|
|
it('should decrease as failures are recorded', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(1);
|
|
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(0);
|
|
});
|
|
|
|
it('should return max attempts after window expires', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('getBlockTimeRemaining', () => {
|
|
it('should return 0 for non-blocked key', () => {
|
|
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
|
|
});
|
|
|
|
it('should return remaining block time for blocked key', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(30000); // Advance 30 seconds
|
|
|
|
const remaining = rateLimiter.getBlockTimeRemaining('192.168.1.1');
|
|
expect(remaining).toBeGreaterThan(29000);
|
|
expect(remaining).toBeLessThanOrEqual(30000);
|
|
});
|
|
|
|
it('should return 0 after block expires', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
expect(rateLimiter.getBlockTimeRemaining('192.168.1.1')).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('cleanup', () => {
|
|
it('should remove expired blocks', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
rateLimiter.cleanup();
|
|
|
|
// After cleanup, the record should be gone
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
});
|
|
|
|
it('should remove expired windows', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(60001);
|
|
|
|
rateLimiter.cleanup();
|
|
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(3);
|
|
});
|
|
|
|
it('should preserve active records', () => {
|
|
rateLimiter.recordFailure('192.168.1.1');
|
|
|
|
vi.advanceTimersByTime(30000); // Half the window
|
|
|
|
rateLimiter.cleanup();
|
|
|
|
expect(rateLimiter.getAttemptsRemaining('192.168.1.1')).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('default configuration', () => {
|
|
it('should use sensible defaults', () => {
|
|
const defaultLimiter = new RateLimiter();
|
|
|
|
// Should have 5 max attempts by default
|
|
expect(defaultLimiter.getAttemptsRemaining('test')).toBe(5);
|
|
});
|
|
});
|
|
});
|