refactor: migrate to Elysia and enhance security middleware
- Replaced Express with Elysia for improved performance and type safety - Integrated Elysia middleware for rate limiting, security headers, and request validation - Refactored security utilities to work with Elysia's context and request handling - Updated token management and validation logic - Added comprehensive security headers and input sanitization - Simplified server initialization and error handling - Updated documentation with new setup and configuration details
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security/index.js';
|
||||
import { Request, Response } from 'express';
|
||||
import { TokenManager, validateRequest, sanitizeInput, errorHandler, rateLimiter, securityHeaders } from '../../src/security/index.js';
|
||||
import { mock, describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const TEST_SECRET = 'test-secret-that-is-long-enough-for-testing-purposes';
|
||||
@@ -50,44 +50,75 @@ describe('Security Module', () => {
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Token has expired');
|
||||
});
|
||||
|
||||
it('should handle invalid token format', () => {
|
||||
const result = TokenManager.validateToken('invalid-token');
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('Invalid token format');
|
||||
});
|
||||
|
||||
it('should handle missing JWT secret', () => {
|
||||
delete process.env.JWT_SECRET;
|
||||
const payload = { data: 'test' };
|
||||
const token = jwt.sign(payload, 'some-secret');
|
||||
const result = TokenManager.validateToken(token);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe('JWT secret not configured');
|
||||
});
|
||||
|
||||
it('should handle rate limiting for failed attempts', () => {
|
||||
const invalidToken = 'x'.repeat(64);
|
||||
const testIp = '127.0.0.1';
|
||||
|
||||
// First attempt
|
||||
const firstResult = TokenManager.validateToken(invalidToken, testIp);
|
||||
expect(firstResult.valid).toBe(false);
|
||||
|
||||
// Multiple failed attempts
|
||||
for (let i = 0; i < 4; i++) {
|
||||
TokenManager.validateToken(invalidToken, testIp);
|
||||
}
|
||||
|
||||
// Next attempt should be rate limited
|
||||
const limitedResult = TokenManager.validateToken(invalidToken, testIp);
|
||||
expect(limitedResult.valid).toBe(false);
|
||||
expect(limitedResult.error).toBe('Too many failed attempts. Please try again later.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Validation', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: jest.Mock;
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let mockNext: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
} as Record<string, string>,
|
||||
},
|
||||
body: {},
|
||||
ip: '127.0.0.1'
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
removeHeader: jest.fn().mockReturnThis()
|
||||
status: mock(() => mockResponse),
|
||||
json: mock(() => mockResponse),
|
||||
setHeader: mock(() => mockResponse),
|
||||
removeHeader: mock(() => mockResponse)
|
||||
};
|
||||
|
||||
mockNext = jest.fn();
|
||||
mockNext = mock(() => { });
|
||||
});
|
||||
|
||||
it('should pass valid requests', () => {
|
||||
if (mockRequest.headers) {
|
||||
mockRequest.headers.authorization = 'Bearer valid-token';
|
||||
}
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true });
|
||||
const validateTokenSpy = mock(() => ({ valid: true }));
|
||||
TokenManager.validateToken = validateTokenSpy;
|
||||
|
||||
validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
validateRequest(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
@@ -97,11 +128,7 @@ describe('Security Module', () => {
|
||||
mockRequest.headers['content-type'] = 'text/plain';
|
||||
}
|
||||
|
||||
validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
validateRequest(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(415);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
@@ -117,11 +144,7 @@ describe('Security Module', () => {
|
||||
delete mockRequest.headers.authorization;
|
||||
}
|
||||
|
||||
validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
validateRequest(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
@@ -135,11 +158,7 @@ describe('Security Module', () => {
|
||||
it('should reject invalid request body', () => {
|
||||
mockRequest.body = null;
|
||||
|
||||
validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
validateRequest(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
@@ -152,9 +171,9 @@ describe('Security Module', () => {
|
||||
});
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: jest.Mock;
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let mockNext: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
@@ -171,19 +190,15 @@ describe('Security Module', () => {
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis()
|
||||
status: mock(() => mockResponse),
|
||||
json: mock(() => mockResponse)
|
||||
};
|
||||
|
||||
mockNext = jest.fn();
|
||||
mockNext = mock(() => { });
|
||||
});
|
||||
|
||||
it('should sanitize HTML tags from request body', () => {
|
||||
sanitizeInput(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
sanitizeInput(mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockRequest.body).toEqual({
|
||||
text: 'Test',
|
||||
@@ -196,19 +211,15 @@ describe('Security Module', () => {
|
||||
|
||||
it('should handle non-object body', () => {
|
||||
mockRequest.body = 'string body';
|
||||
sanitizeInput(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
sanitizeInput(mockRequest, mockResponse, mockNext);
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handler', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockNext: jest.Mock;
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let mockNext: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
@@ -217,22 +228,17 @@ describe('Security Module', () => {
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis()
|
||||
status: mock(() => mockResponse),
|
||||
json: mock(() => mockResponse)
|
||||
};
|
||||
|
||||
mockNext = jest.fn();
|
||||
mockNext = mock(() => { });
|
||||
});
|
||||
|
||||
it('should handle errors in production mode', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const error = new Error('Test error');
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
errorHandler(error, mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
@@ -245,12 +251,7 @@ describe('Security Module', () => {
|
||||
it('should include error message in development mode', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const error = new Error('Test error');
|
||||
errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
mockNext
|
||||
);
|
||||
errorHandler(error, mockRequest, mockResponse, mockNext);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
@@ -262,4 +263,52 @@ describe('Security Module', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiter', () => {
|
||||
it('should limit requests after threshold', async () => {
|
||||
const mockContext = {
|
||||
request: new Request('http://localhost', {
|
||||
headers: new Headers({
|
||||
'x-forwarded-for': '127.0.0.1'
|
||||
})
|
||||
}),
|
||||
set: mock(() => { })
|
||||
};
|
||||
|
||||
// Test multiple requests
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await rateLimiter.derive(mockContext);
|
||||
}
|
||||
|
||||
// The next request should throw
|
||||
try {
|
||||
await rateLimiter.derive(mockContext);
|
||||
expect(false).toBe(true); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect(error.message).toBe('Too many requests from this IP, please try again later');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Headers', () => {
|
||||
it('should set security headers', async () => {
|
||||
const mockHeaders = new Headers();
|
||||
const mockContext = {
|
||||
request: new Request('http://localhost', {
|
||||
headers: mockHeaders
|
||||
}),
|
||||
set: mock(() => { })
|
||||
};
|
||||
|
||||
await securityHeaders.derive(mockContext);
|
||||
|
||||
// Verify that security headers were set
|
||||
const headers = mockContext.request.headers;
|
||||
expect(headers.has('content-security-policy')).toBe(true);
|
||||
expect(headers.has('x-frame-options')).toBe(true);
|
||||
expect(headers.has('x-content-type-options')).toBe(true);
|
||||
expect(headers.has('referrer-policy')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,181 +1,156 @@
|
||||
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { Request, Response } from 'express';
|
||||
import { Mock } from 'bun:test';
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
validateRequest,
|
||||
sanitizeInput,
|
||||
errorHandler,
|
||||
rateLimiter,
|
||||
securityHeaders
|
||||
checkRateLimit,
|
||||
validateRequestHeaders,
|
||||
sanitizeValue,
|
||||
applySecurityHeaders,
|
||||
handleError
|
||||
} from '../../src/security/index.js';
|
||||
|
||||
interface MockRequest extends Partial<Request> {
|
||||
headers: {
|
||||
'content-type'?: string;
|
||||
authorization?: string;
|
||||
};
|
||||
method: string;
|
||||
body: any;
|
||||
ip: string;
|
||||
path: string;
|
||||
}
|
||||
describe('Security Middleware Utilities', () => {
|
||||
describe('Rate Limiter', () => {
|
||||
it('should allow requests under threshold', () => {
|
||||
const ip = '127.0.0.1';
|
||||
expect(() => checkRateLimit(ip, 10)).not.toThrow();
|
||||
});
|
||||
|
||||
interface MockResponse extends Partial<Response> {
|
||||
status: Mock<(code: number) => MockResponse>;
|
||||
json: Mock<(body: any) => MockResponse>;
|
||||
setHeader: Mock<(name: string, value: string) => MockResponse>;
|
||||
removeHeader: Mock<(name: string) => MockResponse>;
|
||||
}
|
||||
it('should throw when requests exceed threshold', () => {
|
||||
const ip = '127.0.0.2';
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
let mockRequest: any;
|
||||
let mockResponse: any;
|
||||
let nextFunction: any;
|
||||
// Simulate multiple requests
|
||||
for (let i = 0; i < 11; i++) {
|
||||
if (i < 10) {
|
||||
expect(() => checkRateLimit(ip, 10)).not.toThrow();
|
||||
} else {
|
||||
expect(() => checkRateLimit(ip, 10)).toThrow('Too many requests from this IP, please try again later');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
method: 'POST',
|
||||
body: {},
|
||||
ip: '127.0.0.1',
|
||||
path: '/api/test'
|
||||
};
|
||||
it('should reset rate limit after window expires', async () => {
|
||||
const ip = '127.0.0.3';
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
removeHeader: jest.fn().mockReturnThis()
|
||||
} as MockResponse;
|
||||
// Simulate multiple requests
|
||||
for (let i = 0; i < 11; i++) {
|
||||
if (i < 10) {
|
||||
expect(() => checkRateLimit(ip, 10, 50)).not.toThrow();
|
||||
}
|
||||
}
|
||||
|
||||
nextFunction = jest.fn();
|
||||
// Wait for rate limit window to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should be able to make requests again
|
||||
expect(() => checkRateLimit(ip, 10, 50)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Validation', () => {
|
||||
it('should pass valid requests', () => {
|
||||
mockRequest.headers.authorization = 'Bearer valid-token';
|
||||
validateRequest(mockRequest, mockResponse, nextFunction);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
it('should validate content type', () => {
|
||||
const mockRequest = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => validateRequestHeaders(mockRequest)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject requests without authorization header', () => {
|
||||
validateRequest(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: 'Missing or invalid authorization header',
|
||||
timestamp: expect.any(String)
|
||||
it('should reject invalid content type', () => {
|
||||
const mockRequest = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'text/plain'
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => validateRequestHeaders(mockRequest)).toThrow('Content-Type must be application/json');
|
||||
});
|
||||
|
||||
it('should reject requests with invalid authorization format', () => {
|
||||
mockRequest.headers.authorization = 'invalid-format';
|
||||
validateRequest(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
error: 'Missing or invalid authorization header',
|
||||
timestamp: expect.any(String)
|
||||
it('should reject large request bodies', () => {
|
||||
const mockRequest = new Request('http://localhost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': '2000000'
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => validateRequestHeaders(mockRequest)).toThrow('Request body too large');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Sanitization', () => {
|
||||
it('should sanitize HTML in request body', () => {
|
||||
mockRequest.body = {
|
||||
it('should sanitize HTML tags', () => {
|
||||
const input = '<script>alert("xss")</script>Hello';
|
||||
const sanitized = sanitizeValue(input);
|
||||
expect(sanitized).toBe('<script>alert("xss")</script>Hello');
|
||||
});
|
||||
|
||||
it('should sanitize nested objects', () => {
|
||||
const input = {
|
||||
text: '<script>alert("xss")</script>Hello',
|
||||
nested: {
|
||||
html: '<img src="x" onerror="alert(1)">World'
|
||||
}
|
||||
};
|
||||
sanitizeInput(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockRequest.body.text).toBe('Hello');
|
||||
expect(mockRequest.body.nested.html).toBe('World');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle non-object bodies', () => {
|
||||
mockRequest.body = '<p>text</p>';
|
||||
sanitizeInput(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockRequest.body).toBe('text');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
const sanitized = sanitizeValue(input);
|
||||
expect(sanitized).toEqual({
|
||||
text: '<script>alert("xss")</script>Hello',
|
||||
nested: {
|
||||
html: '<img src="x" onerror="alert(1)">World'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
mockRequest.body = {
|
||||
const input = {
|
||||
number: 123,
|
||||
boolean: true,
|
||||
array: [1, 2, 3]
|
||||
};
|
||||
sanitizeInput(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockRequest.body).toEqual({
|
||||
number: 123,
|
||||
boolean: true,
|
||||
array: [1, 2, 3]
|
||||
});
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
const sanitized = sanitizeValue(input);
|
||||
expect(sanitized).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handler', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
describe('Security Headers', () => {
|
||||
it('should apply security headers', () => {
|
||||
const mockRequest = new Request('http://localhost');
|
||||
const headers = applySecurityHeaders(mockRequest);
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
expect(headers).toBeDefined();
|
||||
expect(headers['content-security-policy']).toBeDefined();
|
||||
expect(headers['x-frame-options']).toBeDefined();
|
||||
expect(headers['x-content-type-options']).toBeDefined();
|
||||
expect(headers['referrer-policy']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors in production mode', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
const error = new Error('Test error');
|
||||
errorHandler(error, mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
error: 'Internal Server Error',
|
||||
message: undefined,
|
||||
const result = handleError(error, 'production');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: true,
|
||||
message: 'Internal server error',
|
||||
timestamp: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should include error details in development mode', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const error = new Error('Test error');
|
||||
errorHandler(error, mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Test error',
|
||||
stack: expect.any(String),
|
||||
timestamp: expect.any(String)
|
||||
const result = handleError(error, 'development');
|
||||
|
||||
expect(result).toEqual({
|
||||
error: true,
|
||||
message: 'Internal server error',
|
||||
timestamp: expect.any(String),
|
||||
error: 'Test error',
|
||||
stack: expect.any(String)
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error objects', () => {
|
||||
const error = 'String error message';
|
||||
errorHandler(error as any, mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiter', () => {
|
||||
it('should be configured with correct options', () => {
|
||||
expect(rateLimiter).toBeDefined();
|
||||
expect(rateLimiter.windowMs).toBeDefined();
|
||||
expect(rateLimiter.max).toBeDefined();
|
||||
expect(rateLimiter.message).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Headers', () => {
|
||||
it('should set appropriate security headers', () => {
|
||||
securityHeaders(mockRequest, mockResponse, nextFunction);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user