chore: Update configuration and dependencies for enhanced MCP server functionality

- Add RATE_LIMIT_MAX_AUTH_REQUESTS to .env.example for improved rate limiting
- Update bun.lock and package.json to include new dependencies: @anthropic-ai/sdk, express-rate-limit, and their type definitions
- Modify bunfig.toml for build settings and output configuration
- Refactor src/config.ts to incorporate rate limiting settings
- Implement security middleware for enhanced request validation and sanitization
- Introduce rate limiting middleware for API and authentication endpoints
- Add tests for configuration validation and rate limiting functionality
This commit is contained in:
jango-blockchained
2025-03-23 13:00:02 +01:00
parent 2d5ae034c9
commit febc9bd5b5
20 changed files with 1347 additions and 532 deletions

View File

@@ -0,0 +1,106 @@
import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
import { MCPServerConfigSchema } from '../schemas/config.schema.js';
describe('Configuration Validation', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
// Reset environment variables before each test
process.env = { ...originalEnv };
});
afterEach(() => {
// Restore original environment after each test
process.env = originalEnv;
});
test('validates default configuration', () => {
const config = MCPServerConfigSchema.parse({});
expect(config).toBeDefined();
expect(config.port).toBe(3000);
expect(config.environment).toBe('development');
});
test('validates custom port', () => {
const config = MCPServerConfigSchema.parse({ port: 8080 });
expect(config.port).toBe(8080);
});
test('rejects invalid port', () => {
expect(() => MCPServerConfigSchema.parse({ port: 0 })).toThrow();
expect(() => MCPServerConfigSchema.parse({ port: 70000 })).toThrow();
});
test('validates environment values', () => {
expect(() => MCPServerConfigSchema.parse({ environment: 'development' })).not.toThrow();
expect(() => MCPServerConfigSchema.parse({ environment: 'production' })).not.toThrow();
expect(() => MCPServerConfigSchema.parse({ environment: 'test' })).not.toThrow();
expect(() => MCPServerConfigSchema.parse({ environment: 'invalid' })).toThrow();
});
test('validates rate limiting configuration', () => {
const config = MCPServerConfigSchema.parse({
rateLimit: {
maxRequests: 50,
maxAuthRequests: 10
}
});
expect(config.rateLimit.maxRequests).toBe(50);
expect(config.rateLimit.maxAuthRequests).toBe(10);
});
test('rejects invalid rate limit values', () => {
expect(() => MCPServerConfigSchema.parse({
rateLimit: {
maxRequests: 0,
maxAuthRequests: 5
}
})).toThrow();
expect(() => MCPServerConfigSchema.parse({
rateLimit: {
maxRequests: 100,
maxAuthRequests: -1
}
})).toThrow();
});
test('validates execution timeout', () => {
const config = MCPServerConfigSchema.parse({ executionTimeout: 5000 });
expect(config.executionTimeout).toBe(5000);
});
test('rejects invalid execution timeout', () => {
expect(() => MCPServerConfigSchema.parse({ executionTimeout: 500 })).toThrow();
expect(() => MCPServerConfigSchema.parse({ executionTimeout: 400000 })).toThrow();
});
test('validates transport settings', () => {
const config = MCPServerConfigSchema.parse({
useStdioTransport: true,
useHttpTransport: false
});
expect(config.useStdioTransport).toBe(true);
expect(config.useHttpTransport).toBe(false);
});
test('validates CORS settings', () => {
const config = MCPServerConfigSchema.parse({
corsOrigin: 'https://example.com'
});
expect(config.corsOrigin).toBe('https://example.com');
});
test('validates debug settings', () => {
const config = MCPServerConfigSchema.parse({
debugMode: true,
debugStdio: true,
debugHttp: true,
silentStartup: false
});
expect(config.debugMode).toBe(true);
expect(config.debugStdio).toBe(true);
expect(config.debugHttp).toBe(true);
expect(config.silentStartup).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
import { expect, test, describe, beforeAll, afterAll } from 'bun:test';
import express from 'express';
import { apiLimiter, authLimiter } from '../middleware/rate-limit.middleware.js';
import supertest from 'supertest';
describe('Rate Limiting Middleware', () => {
let app: express.Application;
let request: supertest.SuperTest<supertest.Test>;
beforeAll(() => {
app = express();
// Set up test routes with rate limiting
app.use('/api', apiLimiter);
app.use('/auth', authLimiter);
// Test endpoints
app.get('/api/test', (req, res) => {
res.json({ message: 'API test successful' });
});
app.post('/auth/login', (req, res) => {
res.json({ message: 'Login successful' });
});
request = supertest(app);
});
test('allows requests within API rate limit', async () => {
// Make multiple requests within the limit
for (let i = 0; i < 5; i++) {
const response = await request.get('/api/test');
expect(response.status).toBe(200);
expect(response.body.message).toBe('API test successful');
}
});
test('enforces API rate limit', async () => {
// Make more requests than the limit allows
const requests = Array(150).fill(null).map(() =>
request.get('/api/test')
);
const responses = await Promise.all(requests);
// Some requests should be successful, others should be rate limited
const successfulRequests = responses.filter(r => r.status === 200);
const limitedRequests = responses.filter(r => r.status === 429);
expect(successfulRequests.length).toBeGreaterThan(0);
expect(limitedRequests.length).toBeGreaterThan(0);
});
test('allows requests within auth rate limit', async () => {
// Make multiple requests within the limit
for (let i = 0; i < 3; i++) {
const response = await request.post('/auth/login');
expect(response.status).toBe(200);
expect(response.body.message).toBe('Login successful');
}
});
test('enforces stricter auth rate limit', async () => {
// Make more requests than the auth limit allows
const requests = Array(10).fill(null).map(() =>
request.post('/auth/login')
);
const responses = await Promise.all(requests);
// Some requests should be successful, others should be rate limited
const successfulRequests = responses.filter(r => r.status === 200);
const limitedRequests = responses.filter(r => r.status === 429);
expect(successfulRequests.length).toBeLessThan(10);
expect(limitedRequests.length).toBeGreaterThan(0);
});
test('includes rate limit headers', async () => {
const response = await request.get('/api/test');
expect(response.headers['ratelimit-limit']).toBeDefined();
expect(response.headers['ratelimit-remaining']).toBeDefined();
expect(response.headers['ratelimit-reset']).toBeDefined();
});
});

View File

@@ -0,0 +1,169 @@
import { describe, expect, test, beforeEach } from 'bun:test';
import express, { Request, Response } from 'express';
import request from 'supertest';
import { SecurityMiddleware } from '../security/enhanced-middleware';
describe('SecurityMiddleware', () => {
const app = express();
// Apply security middleware
app.use(SecurityMiddleware.createRouter());
// Test routes
app.get('/test', (_req: Request, res: Response) => {
res.status(200).json({ message: 'Test successful' });
});
app.post('/test', (req: Request, res: Response) => {
res.status(200).json(req.body);
});
app.post('/auth/login', (_req: Request, res: Response) => {
res.status(200).json({ message: 'Auth successful' });
});
describe('Security Headers', () => {
test('should set security headers correctly', async () => {
const response = await request(app).get('/test');
expect(response.status).toBe(200);
expect(response.headers['x-frame-options']).toBe('DENY');
expect(response.headers['x-xss-protection']).toBe('1; mode=block');
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
expect(response.headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains; preload');
expect(response.headers['x-permitted-cross-domain-policies']).toBe('none');
expect(response.headers['cross-origin-embedder-policy']).toBe('require-corp');
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin');
expect(response.headers['cross-origin-resource-policy']).toBe('same-origin');
expect(response.headers['origin-agent-cluster']).toBe('?1');
expect(response.headers['x-powered-by']).toBeUndefined();
});
test('should set Content-Security-Policy header correctly', async () => {
const response = await request(app).get('/test');
expect(response.status).toBe(200);
expect(response.headers['content-security-policy']).toContain("default-src 'self'");
expect(response.headers['content-security-policy']).toContain("script-src 'self' 'unsafe-inline'");
expect(response.headers['content-security-policy']).toContain("style-src 'self' 'unsafe-inline'");
expect(response.headers['content-security-policy']).toContain("img-src 'self' data: https:");
expect(response.headers['content-security-policy']).toContain("font-src 'self'");
expect(response.headers['content-security-policy']).toContain("connect-src 'self'");
expect(response.headers['content-security-policy']).toContain("frame-ancestors 'none'");
expect(response.headers['content-security-policy']).toContain("form-action 'self'");
});
});
describe('Request Validation', () => {
test('should reject requests with long URLs', async () => {
const longUrl = '/test?' + 'x'.repeat(2500);
const response = await request(app).get(longUrl);
expect(response.status).toBe(413);
expect(response.body.error).toBe(true);
expect(response.body.message).toContain('URL too long');
});
test('should reject large request bodies', async () => {
const largeBody = { data: 'x'.repeat(2 * 1024 * 1024) }; // 2MB
const response = await request(app)
.post('/test')
.set('Content-Type', 'application/json')
.send(largeBody);
expect(response.status).toBe(413);
expect(response.body.error).toBe(true);
expect(response.body.message).toContain('Request body too large');
});
test('should require correct content type for POST requests', async () => {
const response = await request(app)
.post('/test')
.set('Content-Type', 'text/plain')
.send('test data');
expect(response.status).toBe(415);
expect(response.body.error).toBe(true);
expect(response.body.message).toContain('Content-Type must be application/json');
});
});
describe('Input Sanitization', () => {
test('should sanitize string input with HTML', async () => {
const response = await request(app)
.post('/test')
.set('Content-Type', 'application/json')
.send({ text: '<script>alert("xss")</script>Hello<img src="x" onerror="alert(1)">' });
expect(response.status).toBe(200);
expect(response.body.text).toBe('Hello');
});
test('should sanitize nested object input', async () => {
const response = await request(app)
.post('/test')
.set('Content-Type', 'application/json')
.send({
user: {
name: '<script>alert("xss")</script>John',
bio: '<img src="x" onerror="alert(1)">Developer'
}
});
expect(response.status).toBe(200);
expect(response.body.user.name).toBe('John');
expect(response.body.user.bio).toBe('Developer');
});
test('should sanitize array input', async () => {
const response = await request(app)
.post('/test')
.set('Content-Type', 'application/json')
.send({
items: [
'<script>alert(1)</script>Hello',
'<img src="x" onerror="alert(1)">World'
]
});
expect(response.status).toBe(200);
expect(response.body.items[0]).toBe('Hello');
expect(response.body.items[1]).toBe('World');
});
});
describe('Rate Limiting', () => {
beforeEach(() => {
SecurityMiddleware.clearRateLimits();
});
test('should enforce regular rate limits', async () => {
// Make 50 requests (should succeed)
for (let i = 0; i < 50; i++) {
const response = await request(app).get('/test');
expect(response.status).toBe(200);
}
// 51st request should fail
const response = await request(app).get('/test');
expect(response.status).toBe(429);
expect(response.body.error).toBe(true);
expect(response.body.message).toContain('Too many requests');
});
test('should enforce stricter auth rate limits', async () => {
// Make 3 auth requests (should succeed)
for (let i = 0; i < 3; i++) {
const response = await request(app)
.post('/auth/login')
.set('Content-Type', 'application/json')
.send({});
expect(response.status).toBe(200);
}
// 4th auth request should fail
const response = await request(app)
.post('/auth/login')
.set('Content-Type', 'application/json')
.send({});
expect(response.status).toBe(429);
expect(response.body.error).toBe(true);
expect(response.body.message).toContain('Too many authentication requests');
});
});
});

View File

@@ -3,50 +3,59 @@
* Values can be overridden using environment variables
*/
export interface MCPServerConfig {
// Server configuration
port: number;
environment: string;
import { MCPServerConfigSchema, MCPServerConfigType } from './schemas/config.schema.js';
import { logger } from './utils/logger.js';
// Execution settings
executionTimeout: number;
streamingEnabled: boolean;
function loadConfig(): MCPServerConfigType {
try {
const rawConfig = {
// Server configuration
port: parseInt(process.env.PORT || '3000', 10),
environment: process.env.NODE_ENV || 'development',
// Transport settings
useStdioTransport: boolean;
useHttpTransport: boolean;
// Execution settings
executionTimeout: parseInt(process.env.EXECUTION_TIMEOUT || '30000', 10),
streamingEnabled: process.env.STREAMING_ENABLED === 'true',
// Debug and logging
debugMode: boolean;
debugStdio: boolean;
debugHttp: boolean;
silentStartup: boolean;
// Transport settings
useStdioTransport: process.env.USE_STDIO_TRANSPORT === 'true',
useHttpTransport: process.env.USE_HTTP_TRANSPORT === 'true',
// CORS settings
corsOrigin: string;
// Debug and logging
debugMode: process.env.DEBUG_MODE === 'true',
debugStdio: process.env.DEBUG_STDIO === 'true',
debugHttp: process.env.DEBUG_HTTP === 'true',
silentStartup: process.env.SILENT_STARTUP === 'true',
// CORS settings
corsOrigin: process.env.CORS_ORIGIN || '*',
// Rate limiting
rateLimit: {
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
maxAuthRequests: parseInt(process.env.RATE_LIMIT_MAX_AUTH_REQUESTS || '5', 10),
},
};
// Validate and parse configuration
const validatedConfig = MCPServerConfigSchema.parse(rawConfig);
// Log validation success
if (!validatedConfig.silentStartup) {
logger.info('Configuration validated successfully');
if (validatedConfig.debugMode) {
logger.debug('Current configuration:', validatedConfig);
}
}
return validatedConfig;
} catch (error) {
// Log validation errors
logger.error('Configuration validation failed:', error);
throw new Error('Invalid configuration. Please check your environment variables.');
}
}
export const APP_CONFIG: MCPServerConfig = {
// Server configuration
port: parseInt(process.env.PORT || '3000', 10),
environment: process.env.NODE_ENV || 'development',
// Execution settings
executionTimeout: parseInt(process.env.EXECUTION_TIMEOUT || '30000', 10),
streamingEnabled: process.env.STREAMING_ENABLED === 'true',
// Transport settings
useStdioTransport: process.env.USE_STDIO_TRANSPORT === 'true',
useHttpTransport: process.env.USE_HTTP_TRANSPORT === 'true',
// Debug and logging
debugMode: process.env.DEBUG_MODE === 'true',
debugStdio: process.env.DEBUG_STDIO === 'true',
debugHttp: process.env.DEBUG_HTTP === 'true',
silentStartup: process.env.SILENT_STARTUP === 'true',
// CORS settings
corsOrigin: process.env.CORS_ORIGIN || '*',
};
export const APP_CONFIG = loadConfig();
export type { MCPServerConfigType };
export default APP_CONFIG;

View File

@@ -5,12 +5,16 @@
import express from 'express';
import cors from 'cors';
import swaggerUi from 'swagger-ui-express';
import { MCPServer } from './mcp/MCPServer.js';
import { loggingMiddleware, timeoutMiddleware } from './mcp/middleware/index.js';
import { StdioTransport } from './mcp/transports/stdio.transport.js';
import { HttpTransport } from './mcp/transports/http.transport.js';
import { APP_CONFIG } from './config.js';
import { logger } from "./utils/logger.js";
import { openApiConfig } from './openapi.js';
import { apiLimiter, authLimiter } from './middleware/rate-limit.middleware.js';
import { SecurityMiddleware } from './security/enhanced-middleware.js';
// Home Assistant tools
import { LightsControlTool } from './tools/homeassistant/lights.tool.js';
@@ -111,10 +115,37 @@ async function main() {
if (APP_CONFIG.useHttpTransport) {
logger.info('Using HTTP transport on port ' + APP_CONFIG.port);
const app = express();
// Apply enhanced security middleware
app.use(SecurityMiddleware.applySecurityHeaders);
// CORS configuration
app.use(cors({
origin: APP_CONFIG.corsOrigin
origin: APP_CONFIG.corsOrigin,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400 // 24 hours
}));
// Apply rate limiting to all routes
app.use('/api', apiLimiter);
app.use('/auth', authLimiter);
// Swagger UI setup
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiConfig, {
explorer: true,
customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Home Assistant MCP API Documentation'
}));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
version: process.env.npm_package_version || '1.0.0'
});
});
const httpTransport = new HttpTransport({
port: APP_CONFIG.port,
corsOrigin: APP_CONFIG.corsOrigin,

View File

@@ -0,0 +1,26 @@
import rateLimit from 'express-rate-limit';
import { APP_CONFIG } from '../config.js';
// Create a limiter for API endpoints
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: APP_CONFIG.rateLimit?.maxRequests || 100, // Limit each IP to 100 requests per windowMs
message: {
status: 'error',
message: 'Too many requests from this IP, please try again later.'
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Create a stricter limiter for authentication endpoints
export const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: APP_CONFIG.rateLimit?.maxAuthRequests || 5, // Limit each IP to 5 login requests per hour
message: {
status: 'error',
message: 'Too many login attempts from this IP, please try again later.'
},
standardHeaders: true,
legacyHeaders: false,
});

284
src/openapi.ts Normal file
View File

@@ -0,0 +1,284 @@
import type { OpenAPIV3 } from 'openapi-types'
export const openApiConfig: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Home Assistant MCP API',
description: `
# Home Assistant Model Context Protocol API
The Model Context Protocol (MCP) provides a standardized interface for AI tools to interact with Home Assistant.
This API documentation covers all available endpoints and features of the MCP server.
## Features
- Tool Management
- Real-time Communication
- Health Monitoring
- Rate Limiting
- Authentication
- Server-Sent Events (SSE)
`,
version: '1.0.0',
contact: {
name: 'Home Assistant MCP',
url: 'https://github.com/your-repo/homeassistant-mcp'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Local development server'
}
],
paths: {
'/health': {
get: {
tags: ['Health'],
summary: 'Health check endpoint',
description: 'Returns the current health status and version of the server',
responses: {
'200': {
description: 'Server is healthy',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/HealthCheck'
}
}
}
}
}
}
},
'/api/tools': {
get: {
tags: ['Tools'],
summary: 'List available tools',
description: 'Returns a list of all registered tools and their capabilities',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'List of available tools',
content: {
'application/json': {
schema: {
type: 'array',
items: {
$ref: '#/components/schemas/Tool'
}
}
}
}
},
'401': {
description: 'Unauthorized',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
}
},
'/api/mcp/execute': {
post: {
tags: ['MCP'],
summary: 'Execute a tool command',
description: 'Executes a command using a registered tool',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ExecuteRequest'
}
}
}
},
responses: {
'200': {
description: 'Command executed successfully',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/ExecuteResponse'
}
}
}
},
'400': {
description: 'Invalid request',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
},
'401': {
description: 'Unauthorized',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
}
},
'/api/mcp/stream': {
get: {
tags: ['SSE'],
summary: 'Stream events',
description: 'Opens a Server-Sent Events connection for real-time updates',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'SSE stream established',
content: {
'text/event-stream': {
schema: {
type: 'string'
}
}
}
},
'401': {
description: 'Unauthorized',
content: {
'application/json': {
schema: {
$ref: '#/components/schemas/Error'
}
}
}
}
}
}
}
},
components: {
schemas: {
Error: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'Error code'
},
message: {
type: 'string',
description: 'Error message'
}
},
required: ['code', 'message']
},
HealthCheck: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['ok', 'error'],
description: 'Current health status'
},
version: {
type: 'string',
description: 'Server version'
}
},
required: ['status', 'version']
},
Tool: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Tool name'
},
description: {
type: 'string',
description: 'Tool description'
},
parameters: {
type: 'object',
description: 'Tool parameters schema'
},
returns: {
type: 'object',
description: 'Tool return value schema'
}
},
required: ['name', 'description']
},
ExecuteRequest: {
type: 'object',
properties: {
tool: {
type: 'string',
description: 'Name of the tool to execute'
},
params: {
type: 'object',
description: 'Tool parameters'
}
},
required: ['tool']
},
ExecuteResponse: {
type: 'object',
properties: {
result: {
type: 'object',
description: 'Tool execution result'
},
error: {
type: 'string',
description: 'Error message if execution failed'
}
}
}
},
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token for authentication'
}
}
},
tags: [
{
name: 'Health',
description: 'Health check endpoints for monitoring server status'
},
{
name: 'MCP',
description: 'Model Context Protocol endpoints for tool execution'
},
{
name: 'Tools',
description: 'Tool management endpoints for listing and configuring tools'
},
{
name: 'SSE',
description: 'Server-Sent Events endpoints for real-time updates'
}
],
security: [
{
bearerAuth: []
}
]
}

View File

@@ -0,0 +1,79 @@
import { z } from 'zod';
export const RateLimitSchema = z.object({
maxRequests: z.number().int().min(1).default(100),
maxAuthRequests: z.number().int().min(1).default(5),
});
export const MCPServerConfigSchema = z.object({
// Server configuration
port: z.number().int().min(1).max(65535).default(3000),
environment: z.enum(['development', 'test', 'production']).default('development'),
// Execution settings
executionTimeout: z.number().int().min(1000).max(300000).default(30000),
streamingEnabled: z.boolean().default(false),
// Transport settings
useStdioTransport: z.boolean().default(false),
useHttpTransport: z.boolean().default(true),
// Debug and logging
debugMode: z.boolean().default(false),
debugStdio: z.boolean().default(false),
debugHttp: z.boolean().default(false),
silentStartup: z.boolean().default(false),
// CORS settings
corsOrigin: z.string().default('*'),
// Rate limiting
rateLimit: RateLimitSchema.default({
maxRequests: 100,
maxAuthRequests: 5,
}),
// Speech features
speech: z.object({
enabled: z.boolean().default(false),
wakeWord: z.object({
enabled: z.boolean().default(false),
threshold: z.number().min(0).max(1).default(0.05),
}),
asr: z.object({
enabled: z.boolean().default(false),
model: z.enum(['base', 'small', 'medium', 'large']).default('base'),
engine: z.enum(['faster_whisper', 'whisper']).default('faster_whisper'),
beamSize: z.number().int().min(1).max(10).default(5),
computeType: z.enum(['float32', 'float16', 'int8']).default('float32'),
language: z.string().default('en'),
}),
audio: z.object({
minSpeechDuration: z.number().min(0.1).max(10).default(1.0),
silenceDuration: z.number().min(0.1).max(5).default(0.5),
sampleRate: z.number().int().min(8000).max(48000).default(16000),
channels: z.number().int().min(1).max(2).default(1),
chunkSize: z.number().int().min(256).max(4096).default(1024),
}),
}).default({
enabled: false,
wakeWord: { enabled: false, threshold: 0.05 },
asr: {
enabled: false,
model: 'base',
engine: 'faster_whisper',
beamSize: 5,
computeType: 'float32',
language: 'en',
},
audio: {
minSpeechDuration: 1.0,
silenceDuration: 0.5,
sampleRate: 16000,
channels: 1,
chunkSize: 1024,
},
}),
});
export type MCPServerConfigType = z.infer<typeof MCPServerConfigSchema>;

View File

@@ -0,0 +1,135 @@
import { expect, test, describe, beforeEach, afterEach } from 'bun:test';
import { SecurityMiddleware } from '../enhanced-middleware';
describe('Enhanced Security Middleware', () => {
describe('Security Headers', () => {
test('applies security headers correctly', () => {
const request = new Request('http://localhost');
SecurityMiddleware.applySecurityHeaders(request);
expect(request.headers.get('content-security-policy')).toBeDefined();
expect(request.headers.get('x-frame-options')).toBe('DENY');
expect(request.headers.get('strict-transport-security')).toBeDefined();
expect(request.headers.get('x-xss-protection')).toBe('1; mode=block');
});
});
describe('Request Validation', () => {
test('validates request size', async () => {
const largeBody = 'x'.repeat(2 * 1024 * 1024); // 2MB
const request = new Request('http://localhost', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': largeBody.length.toString()
},
body: JSON.stringify({ data: largeBody })
});
await expect(SecurityMiddleware.validateRequest(request)).rejects.toThrow('Request body too large');
});
test('validates URL length', async () => {
const longUrl = 'http://localhost/' + 'x'.repeat(3000);
const request = new Request(longUrl);
await expect(SecurityMiddleware.validateRequest(request)).rejects.toThrow('URL too long');
});
test('validates and sanitizes POST request body', async () => {
const request = new Request('http://localhost', {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
name: '<script>alert("xss")</script>Hello',
age: 25
})
});
await SecurityMiddleware.validateRequest(request);
const body = await request.json();
expect(body.name).not.toContain('<script>');
expect(body.age).toBe(25);
});
});
describe('Input Sanitization', () => {
test('sanitizes string input', () => {
const input = '<script>alert("xss")</script>Hello<img src="x" onerror="alert(1)">';
const sanitized = SecurityMiddleware.sanitizeInput(input);
expect(sanitized).toBe('Hello');
});
test('sanitizes nested object input', () => {
const input = {
name: '<script>alert("xss")</script>John',
details: {
bio: '<img src="x" onerror="alert(1)">Web Developer'
}
};
const sanitized = SecurityMiddleware.sanitizeInput(input) as any;
expect(sanitized.name).toBe('John');
expect(sanitized.details.bio).toBe('Web Developer');
});
test('sanitizes array input', () => {
const input = [
'<script>alert(1)</script>Hello',
'<img src="x" onerror="alert(1)">World'
];
const sanitized = SecurityMiddleware.sanitizeInput(input) as string[];
expect(sanitized[0]).toBe('Hello');
expect(sanitized[1]).toBe('World');
});
});
describe('Rate Limiting', () => {
beforeEach(() => {
// Reset rate limit stores before each test
(SecurityMiddleware as any).rateLimitStore.clear();
(SecurityMiddleware as any).authLimitStore.clear();
});
test('enforces regular rate limits', () => {
const ip = '127.0.0.1';
// Should allow up to 100 requests
for (let i = 0; i < 100; i++) {
expect(() => SecurityMiddleware.checkRateLimit(ip, false)).not.toThrow();
}
// Should block the 101st request
expect(() => SecurityMiddleware.checkRateLimit(ip, false)).toThrow('Too many requests');
});
test('enforces stricter auth rate limits', () => {
const ip = '127.0.0.1';
// Should allow up to 5 auth requests
for (let i = 0; i < 5; i++) {
expect(() => SecurityMiddleware.checkRateLimit(ip, true)).not.toThrow();
}
// Should block the 6th auth request
expect(() => SecurityMiddleware.checkRateLimit(ip, true)).toThrow('Too many authentication requests');
});
test('resets rate limits after window expires', async () => {
const ip = '127.0.0.1';
// Make max requests
for (let i = 0; i < 100; i++) {
SecurityMiddleware.checkRateLimit(ip, false);
}
// Wait for rate limit window to expire
const store = (SecurityMiddleware as any).rateLimitStore.get(ip);
store.resetTime = Date.now() - 1000; // Set reset time to the past
// Should allow requests again
expect(() => SecurityMiddleware.checkRateLimit(ip, false)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,189 @@
import express, { Request, Response, NextFunction, Router } from 'express';
import sanitizeHtml from 'sanitize-html';
// Custom error type with status code
class SecurityError extends Error {
constructor(public message: string, public statusCode: number) {
super(message);
this.name = 'SecurityError';
}
}
// Security configuration
const SECURITY_CONFIG = {
FRAME_OPTIONS: 'DENY',
XSS_PROTECTION: '1; mode=block',
REFERRER_POLICY: 'strict-origin-when-cross-origin',
HSTS_MAX_AGE: 31536000, // 1 year in seconds
CSP: {
'default-src': ["'self'"],
'script-src': ["'self'", "'unsafe-inline'"],
'style-src': ["'self'", "'unsafe-inline'"],
'img-src': ["'self'", 'data:', 'https:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"]
},
// Request validation config
MAX_URL_LENGTH: 2048,
MAX_BODY_SIZE: '1mb',
// Rate limiting config
RATE_LIMIT: {
WINDOW_MS: 15 * 60 * 1000, // 15 minutes
MAX_REQUESTS: 50,
MESSAGE: 'Too many requests from this IP, please try again later.'
},
AUTH_RATE_LIMIT: {
WINDOW_MS: 15 * 60 * 1000,
MAX_REQUESTS: 3,
MESSAGE: 'Too many authentication attempts from this IP, please try again later.'
}
};
export class SecurityMiddleware {
private static rateLimitStore = new Map<string, { count: number; resetTime: number }>();
private static authLimitStore = new Map<string, { count: number; resetTime: number }>();
private static validateRequest(req: Request): void {
// Check URL length
if (req.originalUrl.length > SECURITY_CONFIG.MAX_URL_LENGTH) {
throw new SecurityError('URL too long', 413);
}
// Check content type for POST requests
if (req.method === 'POST' && req.headers['content-type'] !== 'application/json') {
throw new SecurityError('Content-Type must be application/json', 415);
}
}
private static sanitizeInput(input: unknown): unknown {
if (typeof input === 'string') {
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {}
});
} else if (Array.isArray(input)) {
return input.map(item => SecurityMiddleware.sanitizeInput(item));
} else if (input && typeof input === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
sanitized[key] = SecurityMiddleware.sanitizeInput(value);
}
return sanitized;
}
return input;
}
private static applySecurityHeaders(res: Response): void {
// Remove X-Powered-By header
res.removeHeader('X-Powered-By');
// Set security headers
res.setHeader('X-Frame-Options', SECURITY_CONFIG.FRAME_OPTIONS);
res.setHeader('X-XSS-Protection', SECURITY_CONFIG.XSS_PROTECTION);
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', SECURITY_CONFIG.REFERRER_POLICY);
res.setHeader('Strict-Transport-Security', `max-age=${SECURITY_CONFIG.HSTS_MAX_AGE}; includeSubDomains; preload`);
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
res.setHeader('Origin-Agent-Cluster', '?1');
// Set Content-Security-Policy
const cspDirectives = Object.entries(SECURITY_CONFIG.CSP)
.map(([key, values]) => `${key} ${values.join(' ')}`)
.join('; ');
res.setHeader('Content-Security-Policy', cspDirectives);
}
private static checkRateLimit(req: Request): void {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const now = Date.now();
const isAuth = req.path.startsWith('/auth');
const store = isAuth ? SecurityMiddleware.authLimitStore : SecurityMiddleware.rateLimitStore;
const config = isAuth ? SECURITY_CONFIG.AUTH_RATE_LIMIT : SECURITY_CONFIG.RATE_LIMIT;
let record = store.get(ip);
if (!record || now > record.resetTime) {
record = { count: 1, resetTime: now + config.WINDOW_MS };
} else {
record.count++;
if (record.count > config.MAX_REQUESTS) {
throw new SecurityError(
isAuth ? 'Too many authentication requests' : 'Too many requests',
429
);
}
}
store.set(ip, record);
}
/**
* Create Express router with all security middleware
*/
public static createRouter(): Router {
const router = express.Router();
// Body parser middleware with size limit
router.use(express.json({
limit: SECURITY_CONFIG.MAX_BODY_SIZE,
type: 'application/json'
}));
// Error handler for body-parser errors
router.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
if (err instanceof SyntaxError && 'type' in err && err.type === 'entity.too.large') {
res.status(413).json({
error: true,
message: 'Request body too large'
});
} else {
next(err);
}
});
// Main security middleware
router.use((req: Request, res: Response, next: NextFunction) => {
try {
// Apply security headers
SecurityMiddleware.applySecurityHeaders(res);
// Check rate limits
SecurityMiddleware.checkRateLimit(req);
// Validate request
SecurityMiddleware.validateRequest(req);
// Sanitize input
if (req.body) {
req.body = SecurityMiddleware.sanitizeInput(req.body);
}
next();
} catch (error) {
if (error instanceof SecurityError) {
res.status(error.statusCode).json({
error: true,
message: error.message
});
} else {
res.status(500).json({
error: true,
message: 'Internal server error'
});
}
}
});
return router;
}
// For testing purposes
public static clearRateLimits(): void {
SecurityMiddleware.rateLimitStore.clear();
SecurityMiddleware.authLimitStore.clear();
}
}