mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: Implement API key authentication with rate limiting and secure comparison
- 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.
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { createMockExpressContext } from '../../utils/mocks.js';
|
||||
|
||||
/**
|
||||
* Creates a mock Express context with socket properties for rate limiter support
|
||||
*/
|
||||
function createMockExpressContextWithSocket() {
|
||||
const ctx = createMockExpressContext();
|
||||
ctx.req.socket = { remoteAddress: '127.0.0.1' } as any;
|
||||
ctx.res.setHeader = vi.fn().mockReturnThis();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: auth.ts reads AUTOMAKER_API_KEY at module load time.
|
||||
* We need to reset modules and reimport for each test to get fresh state.
|
||||
@@ -29,7 +39,7 @@ describe('auth.ts', () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
@@ -45,7 +55,7 @@ describe('auth.ts', () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = 'wrong-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
@@ -62,7 +72,7 @@ describe('auth.ts', () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'test-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = 'test-secret-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
@@ -113,4 +123,197 @@ describe('auth.ts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - AUTOMAKER_API_KEY not set', () => {
|
||||
it('should allow requests without any authentication when API key is not configured', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow requests even with invalid key header when API key is not configured', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContext();
|
||||
req.headers['x-api-key'] = 'some-random-key';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report auth as disabled when no API key is configured', async () => {
|
||||
delete process.env.AUTOMAKER_API_KEY;
|
||||
|
||||
const { isAuthEnabled, getAuthStatus } = await import('@/lib/auth.js');
|
||||
|
||||
expect(isAuthEnabled()).toBe(false);
|
||||
expect(getAuthStatus()).toEqual({
|
||||
enabled: false,
|
||||
method: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - authentication correctness', () => {
|
||||
it('should correctly authenticate with matching API key', async () => {
|
||||
const testKey = 'correct-secret-key-12345';
|
||||
process.env.AUTOMAKER_API_KEY = testKey;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = testKey;
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject keys that differ by a single character', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'correct-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = 'correct-secret-keY'; // Last char uppercase
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject keys with extra characters', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = 'secret-key-extra';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject keys that are a prefix of the actual key', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'full-secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = 'full-secret';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject empty string API key header', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'secret-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = '';
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
// Empty string is falsy, so should get 401 (no key provided)
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle keys with special characters correctly', async () => {
|
||||
const specialKey = 'key-with-$pecial!@#chars_123';
|
||||
process.env.AUTOMAKER_API_KEY = specialKey;
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.headers['x-api-key'] = specialKey;
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - rate limiting', () => {
|
||||
it('should block requests after multiple failed attempts', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'correct-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
|
||||
|
||||
// Reset the rate limiter for this test
|
||||
apiKeyRateLimiter.reset('192.168.1.100');
|
||||
|
||||
// Simulate multiple failed attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.socket.remoteAddress = '192.168.1.100';
|
||||
req.headers['x-api-key'] = 'wrong-key';
|
||||
authMiddleware(req, res, next);
|
||||
}
|
||||
|
||||
// Next request should be rate limited
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.socket.remoteAddress = '192.168.1.100';
|
||||
req.headers['x-api-key'] = 'correct-key'; // Even with correct key
|
||||
|
||||
authMiddleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(429);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
apiKeyRateLimiter.reset('192.168.1.100');
|
||||
});
|
||||
|
||||
it('should reset rate limit on successful authentication', async () => {
|
||||
process.env.AUTOMAKER_API_KEY = 'correct-key';
|
||||
|
||||
const { authMiddleware } = await import('@/lib/auth.js');
|
||||
const { apiKeyRateLimiter } = await import('@/lib/rate-limiter.js');
|
||||
|
||||
// Reset the rate limiter for this test
|
||||
apiKeyRateLimiter.reset('192.168.1.101');
|
||||
|
||||
// Simulate a few failed attempts (not enough to trigger block)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { req, res, next } = createMockExpressContextWithSocket();
|
||||
req.socket.remoteAddress = '192.168.1.101';
|
||||
req.headers['x-api-key'] = 'wrong-key';
|
||||
authMiddleware(req, res, next);
|
||||
}
|
||||
|
||||
// Successful authentication should reset the counter
|
||||
const {
|
||||
req: successReq,
|
||||
res: successRes,
|
||||
next: successNext,
|
||||
} = createMockExpressContextWithSocket();
|
||||
successReq.socket.remoteAddress = '192.168.1.101';
|
||||
successReq.headers['x-api-key'] = 'correct-key';
|
||||
|
||||
authMiddleware(successReq, successRes, successNext);
|
||||
|
||||
expect(successNext).toHaveBeenCalled();
|
||||
|
||||
// After reset, we should have full attempts available again
|
||||
expect(apiKeyRateLimiter.getAttemptsRemaining('192.168.1.101')).toBe(5);
|
||||
|
||||
// Cleanup
|
||||
apiKeyRateLimiter.reset('192.168.1.101');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
249
apps/server/tests/unit/lib/rate-limiter.test.ts
Normal file
249
apps/server/tests/unit/lib/rate-limiter.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user