diff --git a/.cursor/tasks b/.cursor/tasks new file mode 100644 index 0000000..eec72b3 --- /dev/null +++ b/.cursor/tasks @@ -0,0 +1,89 @@ + +### 1. Code Quality & Structure + +- **Refactor Common Logic:** + Identify duplicated logic (e.g., in token validations, SSE error handling, and API endpoints) and extract it into shared utility functions or middleware. This will reduce errors and simplify maintenance. + +- **Improve Type Safety:** + Leverage TypeScript’s capabilities by refining interfaces and types—especially for API requests/responses and automation configurations. Consider creating more granular type definitions for advanced scenarios. + +- **Modularize and Organize Code:** + Ensure that features like device control, automation management, and SSE handling are grouped into well-defined modules. Use dependency injection where possible to decouple component implementations from their usage. + +--- + +### 2. API & Feature Enhancements + +- **Enhanced Authentication & Security:** + - Strengthen token validation by adding expiration checks and perhaps support for multiple token schemes (e.g., JWT). + - Introduce role-based access controls for varying user privileges. + - Implement rate limiting and request throttling to prevent abuse and ensure stability. + +- **SSE & Real-Time Updates:** + - Enhance the SSE system to handle reconnection strategies, backoff algorithms, and error recovery automatically. + - Consider grouping subscriptions or providing a filtering mechanism on the server side to reduce unnecessary data transfer. + +- **Logging and Monitoring:** + - Integrate structured logging (using JSON logs or a dedicated logging library) so that each endpoint, including error responses, contains detailed context. + - Link logging with performance metrics to capture slow endpoints, especially for real-time updates and automation configurations. + +--- + +### 3. Testing and Documentation + +- **Expanded Test Coverage:** + - Write additional unit tests and integration tests, particularly for edge cases in automation rules and error scenarios. + - Include tests for correct error handling in SSE, API endpoints, and when processing dynamic parameters. + +- **Live Documentation:** + - Enhance the existing README and developer guides with setup instructions and API reference details. + - Consider integrating tools (e.g., Swagger or Postman) to auto-generate interactive API documentation for easier onboarding. + +--- + +### 4. Performance Optimization + +- **Bun Runtime Optimizations:** + - Benchmark performance-critical paths (like SSE and automation processing) to identify latency or memory bottlenecks. + - Use caching strategies where possible to reduce repetitive tasks (e.g., caching SSE subscription results or frequently requested device states). + +- **Concurrent Processing:** + - Evaluate asynchronous patterns to further optimize I/O operations. Consider using job queues or background workers for tasks that could be decoupled from the main request/response cycle. + +--- + +### 5. User Experience and Interface + +- **CLI Enhancements:** + - Improve the command-line interface with clearer prompts, improved handling of invalid inputs, and built-in help texts for new users. + - Look into adding progress indicators or spinners during long-running operations (e.g., during data collection or AI analysis). + +- **Dashboard or Admin Panel:** + - Consider developing a lightweight web dashboard that visualizes device states, automation statuses, and real-time event logs—making it easier for users or admins to monitor the system. + +--- + +### 6. AI Integration Improvements + +- **Refine OpenAI Interactions:** + - Enhance error handling and retries when dealing with OpenAI API calls. Provide fallback scenarios (as already partly implemented) and clearer error messages. + - Update the prompt templates based on real usage patterns. You might even allow for user-customizable templates for different home automation scenarios. + +- **Contextual Analysis:** + - Expand the analysis functionality so that the AI can provide more context-specific recommendations. For instance, analyze and suggest improvements in automation rules, security configurations, or performance optimizations in a more granular way. + +--- + +### 7. Final Polishing and Deployment + +- **Environment Configurations:** + - Ensure that environment variables and configuration files (like `.env.example`) are thoroughly documented. + - Automate configuration checks and provide clear error logging if critical configuration values (such as tokens or host URLs) are missing. + +- **Deployment Ready:** + - Finalize Docker configurations and scripts to enable smooth containerized deployments, perhaps including orchestration hints for multi-instance or load-balanced deployments. + - Consider setting up a CI/CD pipeline to run tests, linting, and automated builds with every commit/pull request. + +- **UX/UI Finishing Touches:** + - Polish any remaining rough edges in the user interface or command output. + - Ensure consistent ANSI coloring/logging outputs and friendly error/warning messages across all user touchpoints (CLI, API, and dashboard). diff --git a/.env.example b/.env.example index bca8a1f..eb5b524 100644 --- a/.env.example +++ b/.env.example @@ -36,26 +36,50 @@ RATE_LIMIT_REGULAR=100 # Requests per minute per IP for WebSocket connections RATE_LIMIT_WEBSOCKET=1000 -# Security -# JWT secret for token generation (change this in production!) -JWT_SECRET=your_jwt_secret_key - -# CORS configuration (comma-separated list of allowed origins) -CORS_ORIGINS=http://localhost:3000,http://localhost:8123 - -# Test Configuration -# Only needed if running tests -TEST_HASS_HOST=http://localhost:8123 -TEST_HASS_TOKEN=test_token -TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket -TEST_PORT=3001 - # Security Configuration -JWT_SECRET=your-secret-key +# JWT Configuration +JWT_SECRET=your_jwt_secret_key_min_32_chars +JWT_EXPIRY=86400000 +JWT_MAX_AGE=2592000000 +JWT_ALGORITHM=HS256 # Rate Limiting -RATE_LIMIT_WINDOW_MS=900000 # 15 minutes -RATE_LIMIT_MAX=100 +RATE_LIMIT_WINDOW=900000 +RATE_LIMIT_MAX_REQUESTS=100 + +# Token Security +TOKEN_MIN_LENGTH=32 +MAX_FAILED_ATTEMPTS=5 +LOCKOUT_DURATION=900000 + +# CORS Configuration +CORS_ORIGINS=http://localhost:3000,http://localhost:8123 +CORS_METHODS=GET,POST,PUT,DELETE,OPTIONS +CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With +CORS_EXPOSED_HEADERS= +CORS_CREDENTIALS=true +CORS_MAX_AGE=86400 + +# Content Security Policy +CSP_ENABLED=true +CSP_REPORT_ONLY=false +CSP_REPORT_URI= + +# SSL/TLS Configuration +REQUIRE_HTTPS=true +HSTS_MAX_AGE=31536000 +HSTS_INCLUDE_SUBDOMAINS=true +HSTS_PRELOAD=true + +# Cookie Security +COOKIE_SECRET=your_cookie_secret_key_min_32_chars +COOKIE_SECURE=true +COOKIE_HTTP_ONLY=true +COOKIE_SAME_SITE=Strict + +# Request Limits +MAX_REQUEST_SIZE=1048576 +MAX_REQUEST_FIELDS=1000 # SSE Configuration SSE_MAX_CLIENTS=1000 @@ -70,4 +94,11 @@ LOG_COMPRESS=true LOG_REQUESTS=true # Version -VERSION=0.1.0 \ No newline at end of file +VERSION=0.1.0 + +# Test Configuration +# Only needed if running tests +TEST_HASS_HOST=http://localhost:8123 +TEST_HASS_TOKEN=test_token +TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket +TEST_PORT=3001 \ No newline at end of file diff --git a/README.md b/README.md index 6903e67..1b76add 100644 --- a/README.md +++ b/README.md @@ -499,6 +499,11 @@ bun run lint:fix - 🧪 Test coverage reports - 📝 Documentation generation +## Author + +This project was initiated by [T T]() and is mainly developed by [Jango Blockchain](https://github.com/jango-blockchained). + + ## License MIT License - See [LICENSE](LICENSE) file diff --git a/src/config/security.config.ts b/src/config/security.config.ts new file mode 100644 index 0000000..b28d321 --- /dev/null +++ b/src/config/security.config.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; + +// Security configuration schema +const securityConfigSchema = z.object({ + // JWT Configuration + JWT_SECRET: z.string().min(32), + JWT_EXPIRY: z.number().default(24 * 60 * 60 * 1000), // 24 hours + JWT_MAX_AGE: z.number().default(30 * 24 * 60 * 60 * 1000), // 30 days + JWT_ALGORITHM: z.enum(['HS256', 'HS384', 'HS512']).default('HS256'), + + // Rate Limiting + RATE_LIMIT_WINDOW: z.number().default(15 * 60 * 1000), // 15 minutes + RATE_LIMIT_MAX_REQUESTS: z.number().default(100), + RATE_LIMIT_WEBSOCKET: z.number().default(1000), + + // Token Security + TOKEN_MIN_LENGTH: z.number().default(32), + MAX_FAILED_ATTEMPTS: z.number().default(5), + LOCKOUT_DURATION: z.number().default(15 * 60 * 1000), // 15 minutes + + // CORS Configuration + CORS_ORIGINS: z.array(z.string()).default(['http://localhost:3000', 'http://localhost:8123']), + CORS_METHODS: z.array(z.string()).default(['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']), + CORS_ALLOWED_HEADERS: z.array(z.string()).default([ + 'Content-Type', + 'Authorization', + 'X-Requested-With' + ]), + CORS_EXPOSED_HEADERS: z.array(z.string()).default([]), + CORS_CREDENTIALS: z.boolean().default(true), + CORS_MAX_AGE: z.number().default(24 * 60 * 60), // 24 hours + + // Content Security Policy + CSP_ENABLED: z.boolean().default(true), + CSP_REPORT_ONLY: z.boolean().default(false), + CSP_REPORT_URI: z.string().optional(), + + // SSL/TLS Configuration + REQUIRE_HTTPS: z.boolean().default(process.env.NODE_ENV === 'production'), + HSTS_MAX_AGE: z.number().default(31536000), // 1 year + HSTS_INCLUDE_SUBDOMAINS: z.boolean().default(true), + HSTS_PRELOAD: z.boolean().default(true), + + // Cookie Security + COOKIE_SECRET: z.string().min(32).optional(), + COOKIE_SECURE: z.boolean().default(process.env.NODE_ENV === 'production'), + COOKIE_HTTP_ONLY: z.boolean().default(true), + COOKIE_SAME_SITE: z.enum(['Strict', 'Lax', 'None']).default('Strict'), + + // Request Limits + MAX_REQUEST_SIZE: z.number().default(1024 * 1024), // 1MB + MAX_REQUEST_FIELDS: z.number().default(1000), +}); + +// Parse environment variables +const parseEnvConfig = () => { + const config = { + JWT_SECRET: process.env.JWT_SECRET || 'default_secret_key_change_in_production', + JWT_EXPIRY: parseInt(process.env.JWT_EXPIRY || '86400000'), + JWT_MAX_AGE: parseInt(process.env.JWT_MAX_AGE || '2592000000'), + JWT_ALGORITHM: process.env.JWT_ALGORITHM || 'HS256', + + RATE_LIMIT_WINDOW: parseInt(process.env.RATE_LIMIT_WINDOW || '900000'), + RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'), + RATE_LIMIT_WEBSOCKET: parseInt(process.env.RATE_LIMIT_WEBSOCKET || '1000'), + + TOKEN_MIN_LENGTH: parseInt(process.env.TOKEN_MIN_LENGTH || '32'), + MAX_FAILED_ATTEMPTS: parseInt(process.env.MAX_FAILED_ATTEMPTS || '5'), + LOCKOUT_DURATION: parseInt(process.env.LOCKOUT_DURATION || '900000'), + + CORS_ORIGINS: (process.env.CORS_ORIGINS || 'http://localhost:3000,http://localhost:8123') + .split(',') + .map(origin => origin.trim()), + CORS_METHODS: (process.env.CORS_METHODS || 'GET,POST,PUT,DELETE,OPTIONS') + .split(',') + .map(method => method.trim()), + CORS_ALLOWED_HEADERS: (process.env.CORS_ALLOWED_HEADERS || 'Content-Type,Authorization,X-Requested-With') + .split(',') + .map(header => header.trim()), + CORS_EXPOSED_HEADERS: (process.env.CORS_EXPOSED_HEADERS || '') + .split(',') + .filter(Boolean) + .map(header => header.trim()), + CORS_CREDENTIALS: process.env.CORS_CREDENTIALS !== 'false', + CORS_MAX_AGE: parseInt(process.env.CORS_MAX_AGE || '86400'), + + CSP_ENABLED: process.env.CSP_ENABLED !== 'false', + CSP_REPORT_ONLY: process.env.CSP_REPORT_ONLY === 'true', + CSP_REPORT_URI: process.env.CSP_REPORT_URI, + + REQUIRE_HTTPS: process.env.REQUIRE_HTTPS !== 'false' && process.env.NODE_ENV === 'production', + HSTS_MAX_AGE: parseInt(process.env.HSTS_MAX_AGE || '31536000'), + HSTS_INCLUDE_SUBDOMAINS: process.env.HSTS_INCLUDE_SUBDOMAINS !== 'false', + HSTS_PRELOAD: process.env.HSTS_PRELOAD !== 'false', + + COOKIE_SECRET: process.env.COOKIE_SECRET, + COOKIE_SECURE: process.env.COOKIE_SECURE !== 'false' && process.env.NODE_ENV === 'production', + COOKIE_HTTP_ONLY: process.env.COOKIE_HTTP_ONLY !== 'false', + COOKIE_SAME_SITE: (process.env.COOKIE_SAME_SITE || 'Strict') as 'Strict' | 'Lax' | 'None', + + MAX_REQUEST_SIZE: parseInt(process.env.MAX_REQUEST_SIZE || '1048576'), + MAX_REQUEST_FIELDS: parseInt(process.env.MAX_REQUEST_FIELDS || '1000'), + }; + + return securityConfigSchema.parse(config); +}; + +// Export the validated configuration +export const SECURITY_CONFIG = parseEnvConfig(); + +// Export types +export type SecurityConfig = z.infer; \ No newline at end of file diff --git a/src/middleware/index.ts b/src/middleware/index.ts index e1d40b6..c415709 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from 'express'; import { HASS_CONFIG, RATE_LIMIT_CONFIG } from '../config/index.js'; import rateLimit from 'express-rate-limit'; +import { TokenManager } from '../security/index.js'; // Rate limiter middleware export const rateLimiter = rateLimit({ @@ -24,34 +25,79 @@ export const wsRateLimiter = rateLimit({ } }); -// Security headers middleware -export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'DENY'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); +// Authentication middleware +export const authenticate = (req: Request, res: Response, next: NextFunction) => { + const token = req.headers.authorization?.replace('Bearer ', '') || ''; + const clientIp = req.ip || req.socket.remoteAddress || ''; + + const validationResult = TokenManager.validateToken(token, clientIp); + + if (!validationResult.valid) { + return res.status(401).json({ + success: false, + message: 'Unauthorized', + error: validationResult.error || 'Invalid token', + timestamp: new Date().toISOString() + }); + } + next(); }; -// Request validation middleware +// Enhanced security headers middleware +export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => { + // Set strict security headers + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss: https:;"); + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); + res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + next(); +}; + +// Enhanced request validation middleware export const validateRequest = (req: Request, res: Response, next: NextFunction) => { + // Skip validation for health check endpoints + if (req.path === '/health' || req.path === '/mcp') { + return next(); + } + // Validate content type for POST/PUT/PATCH requests if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) { return res.status(415).json({ success: false, - message: 'Content-Type must be application/json' + message: 'Unsupported Media Type', + error: 'Content-Type must be application/json', + timestamp: new Date().toISOString() }); } // Validate request body size const contentLength = parseInt(req.headers['content-length'] || '0', 10); - if (contentLength > 1024 * 1024) { // 1MB limit + const maxSize = 1024 * 1024; // 1MB limit + if (contentLength > maxSize) { return res.status(413).json({ success: false, - message: 'Request body too large' + message: 'Payload Too Large', + error: `Request body must not exceed ${maxSize} bytes`, + timestamp: new Date().toISOString() }); } + // Validate request body structure + if (req.method !== 'GET' && req.body) { + if (typeof req.body !== 'object' || Array.isArray(req.body)) { + return res.status(400).json({ + success: false, + message: 'Bad Request', + error: 'Invalid request body structure', + timestamp: new Date().toISOString() + }); + } + } + next(); }; @@ -84,21 +130,7 @@ export const sanitizeInput = (req: Request, _res: Response, next: NextFunction) next(); }; -// Authentication middleware -export const authenticate = (req: Request, res: Response, next: NextFunction) => { - const token = req.headers.authorization?.replace('Bearer ', ''); - - if (!token || token !== HASS_CONFIG.TOKEN) { - return res.status(401).json({ - success: false, - message: 'Unauthorized - Invalid token' - }); - } - - next(); -}; - -// Error handling middleware +// Enhanced error handling middleware export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => { console.error('Error:', err); @@ -106,8 +138,9 @@ export const errorHandler = (err: Error, _req: Request, res: Response, _next: Ne if (err.name === 'ValidationError') { return res.status(400).json({ success: false, - message: 'Validation error', - details: err.message + message: 'Validation Error', + error: err.message, + timestamp: new Date().toISOString() }); } @@ -115,15 +148,26 @@ export const errorHandler = (err: Error, _req: Request, res: Response, _next: Ne return res.status(401).json({ success: false, message: 'Unauthorized', - details: err.message + error: err.message, + timestamp: new Date().toISOString() + }); + } + + if (err.name === 'ForbiddenError') { + return res.status(403).json({ + success: false, + message: 'Forbidden', + error: err.message, + timestamp: new Date().toISOString() }); } // Default error response res.status(500).json({ success: false, - message: 'Internal server error', - details: process.env.NODE_ENV === 'development' ? err.message : undefined + message: 'Internal Server Error', + error: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred', + timestamp: new Date().toISOString() }); }; diff --git a/src/routes/sse.routes.ts b/src/routes/sse.routes.ts index 342892b..202fc3f 100644 --- a/src/routes/sse.routes.ts +++ b/src/routes/sse.routes.ts @@ -2,28 +2,35 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { sseManager } from '../sse/index.js'; import { TokenManager } from '../security/index.js'; +import { middleware } from '../middleware/index.js'; const router = Router(); // SSE endpoints -router.get('/subscribe', (req, res) => { +router.get('/subscribe_events', middleware.wsRateLimiter, (req, res) => { try { - // Get token from query parameter - const token = req.query.token?.toString(); + // Get token from query parameter and validate + const token = req.query.token?.toString() || ''; + const clientIp = req.ip || req.socket.remoteAddress || ''; + const validationResult = TokenManager.validateToken(token, clientIp); - if (!token || !TokenManager.validateToken(token)) { + if (!validationResult.valid) { return res.status(401).json({ success: false, - message: 'Unauthorized - Invalid token' + message: 'Unauthorized', + error: validationResult.error, + timestamp: new Date().toISOString() }); } - // Set SSE headers + // Set SSE headers with enhanced security res.writeHead(200, { 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache, no-transform', 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*' + 'X-Accel-Buffering': 'no', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': 'true' }); // Send initial connection message @@ -36,49 +43,51 @@ router.get('/subscribe', (req, res) => { const clientId = uuidv4(); const client = { id: clientId, + ip: clientIp, + connectedAt: new Date(), send: (data: string) => { res.write(`data: ${data}\n\n`); } }; - // Add client to SSE manager + // Add client to SSE manager with enhanced tracking const sseClient = sseManager.addClient(client, token); if (!sseClient || !sseClient.authenticated) { - res.write(`data: ${JSON.stringify({ + const errorMessage = JSON.stringify({ type: 'error', message: sseClient ? 'Authentication failed' : 'Maximum client limit reached', timestamp: new Date().toISOString() - })}\n\n`); + }); + res.write(`data: ${errorMessage}\n\n`); return res.end(); } - // Subscribe to events if specified - const events = req.query.events?.toString().split(',').filter(Boolean); - if (events?.length) { - events.forEach(event => sseManager.subscribeToEvent(clientId, event)); - } - - // Subscribe to entity if specified - const entityId = req.query.entity_id?.toString(); - if (entityId) { - sseManager.subscribeToEntity(clientId, entityId); - } - - // Subscribe to domain if specified - const domain = req.query.domain?.toString(); - if (domain) { - sseManager.subscribeToDomain(clientId, domain); - } - // Handle client disconnect req.on('close', () => { sseManager.removeClient(clientId); + console.log(`Client ${clientId} disconnected at ${new Date().toISOString()}`); + }); + + // Handle errors + req.on('error', (error) => { + console.error(`SSE Error for client ${clientId}:`, error); + const errorMessage = JSON.stringify({ + type: 'error', + message: 'Connection error', + timestamp: new Date().toISOString() + }); + res.write(`data: ${errorMessage}\n\n`); + sseManager.removeClient(clientId); + res.end(); }); } catch (error) { + console.error('SSE Setup Error:', error); res.status(500).json({ success: false, - message: error instanceof Error ? error.message : 'Unknown error occurred' + message: 'Internal Server Error', + error: error instanceof Error ? error.message : 'An unexpected error occurred', + timestamp: new Date().toISOString() }); } }); @@ -96,4 +105,4 @@ router.get('/stats', async (req, res) => { } }); -export { router as sseRoutes }; \ No newline at end of file +export default router; \ No newline at end of file diff --git a/src/security/index.ts b/src/security/index.ts index b4b66f6..ca58d89 100644 --- a/src/security/index.ts +++ b/src/security/index.ts @@ -47,6 +47,18 @@ const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; const AUTH_TAG_LENGTH = 16; +// Security configuration +const SECURITY_CONFIG = { + TOKEN_EXPIRY: 24 * 60 * 60 * 1000, // 24 hours + MAX_TOKEN_AGE: 30 * 24 * 60 * 60 * 1000, // 30 days + MIN_TOKEN_LENGTH: 32, + MAX_FAILED_ATTEMPTS: 5, + LOCKOUT_DURATION: 15 * 60 * 1000, // 15 minutes +}; + +// Track failed authentication attempts +const failedAttempts = new Map(); + export class TokenManager { /** * Encrypts a token using AES-256-GCM @@ -114,45 +126,123 @@ export class TokenManager { } /** - * Validates a JWT token + * Validates a JWT token with enhanced security checks */ - static validateToken(token: string): boolean { + static validateToken(token: string, ip?: string): { valid: boolean; error?: string } { if (!token || typeof token !== 'string') { - return false; + return { valid: false, error: 'Invalid token format' }; + } + + // Check for token length + if (token.length < SECURITY_CONFIG.MIN_TOKEN_LENGTH) { + return { valid: false, error: 'Token length below minimum requirement' }; + } + + // Check for rate limiting + if (ip) { + const attempts = failedAttempts.get(ip); + if (attempts) { + const timeSinceLastAttempt = Date.now() - attempts.lastAttempt; + if (attempts.count >= SECURITY_CONFIG.MAX_FAILED_ATTEMPTS) { + if (timeSinceLastAttempt < SECURITY_CONFIG.LOCKOUT_DURATION) { + return { valid: false, error: 'Too many failed attempts. Please try again later.' }; + } + // Reset after lockout period + failedAttempts.delete(ip); + } + } } try { const decoded = jwt.decode(token); if (!decoded || typeof decoded !== 'object') { - return false; + this.recordFailedAttempt(ip); + return { valid: false, error: 'Invalid token structure' }; } - // Check for expiration - if (!decoded.exp) { - return false; + // Enhanced expiration checks + if (!decoded.exp || !decoded.iat) { + this.recordFailedAttempt(ip); + return { valid: false, error: 'Token missing required claims' }; } const now = Math.floor(Date.now() / 1000); if (decoded.exp <= now) { - return false; + this.recordFailedAttempt(ip); + return { valid: false, error: 'Token has expired' }; } - // Verify signature using the secret from environment variable + // Check token age + const tokenAge = (now - decoded.iat) * 1000; + if (tokenAge > SECURITY_CONFIG.MAX_TOKEN_AGE) { + this.recordFailedAttempt(ip); + return { valid: false, error: 'Token exceeds maximum age limit' }; + } + + // Verify signature const secret = process.env.JWT_SECRET; if (!secret) { - return false; + return { valid: false, error: 'JWT secret not configured' }; } try { jwt.verify(token, secret); - return true; - } catch { - return false; + // Reset failed attempts on successful validation + if (ip) { + failedAttempts.delete(ip); + } + return { valid: true }; + } catch (error) { + this.recordFailedAttempt(ip); + return { valid: false, error: 'Invalid token signature' }; } - } catch { - return false; + } catch (error) { + this.recordFailedAttempt(ip); + return { valid: false, error: 'Token validation failed' }; } } + + /** + * Records a failed authentication attempt + */ + private static recordFailedAttempt(ip?: string): void { + if (!ip) return; + + const attempts = failedAttempts.get(ip) || { count: 0, lastAttempt: 0 }; + const now = Date.now(); + + // Reset count if last attempt was outside lockout period + if (now - attempts.lastAttempt > SECURITY_CONFIG.LOCKOUT_DURATION) { + attempts.count = 1; + } else { + attempts.count++; + } + + attempts.lastAttempt = now; + failedAttempts.set(ip, attempts); + } + + /** + * Generates a new JWT token with enhanced security + */ + static generateToken(payload: object, expiresIn: number = SECURITY_CONFIG.TOKEN_EXPIRY): string { + const secret = process.env.JWT_SECRET; + if (!secret) { + throw new Error('JWT secret not configured'); + } + + return jwt.sign( + { + ...payload, + iat: Math.floor(Date.now() / 1000), + }, + secret, + { + expiresIn: Math.floor(expiresIn / 1000), + algorithm: 'HS256' + } + ); + } } // Request validation middleware diff --git a/src/sse/index.ts b/src/sse/index.ts index 8899ade..2aabfab 100644 --- a/src/sse/index.ts +++ b/src/sse/index.ts @@ -2,6 +2,12 @@ import { EventEmitter } from 'events'; import { HassEntity, HassEvent } from '../interfaces/hass.js'; import { TokenManager } from '../security/index.js'; +// Constants +const DEFAULT_MAX_CLIENTS = 1000; +const DEFAULT_PING_INTERVAL = 30000; // 30 seconds +const DEFAULT_CLEANUP_INTERVAL = 60000; // 1 minute +const DEFAULT_MAX_CONNECTION_AGE = 24 * 60 * 60 * 1000; // 24 hours + interface RateLimit { count: number; lastReset: number; @@ -9,63 +15,80 @@ interface RateLimit { export interface SSEClient { id: string; - send: (data: string) => void; - subscriptions: { - entities: Set; - events: Set; - domains: Set; - }; + ip: string; + connectedAt: Date; + lastPingAt?: Date; + subscriptions: Set; authenticated: boolean; + send: (data: string) => void; rateLimit: RateLimit; - lastPing: number; connectionTime: number; } +interface ClientStats { + id: string; + ip: string; + connectedAt: Date; + lastPingAt?: Date; + subscriptionCount: number; + connectionDuration: number; +} + export class SSEManager extends EventEmitter { private clients: Map = new Map(); private static instance: SSEManager | null = null; private entityStates: Map = new Map(); + private readonly maxClients: number; + private readonly pingInterval: number; + private readonly cleanupInterval: number; + private readonly maxConnectionAge: number; - // Configuration - private readonly MAX_CLIENTS = 100; - private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute - private readonly RATE_LIMIT_MAX_REQUESTS = 1000; - private readonly CLIENT_TIMEOUT = 300000; // 5 minutes - private readonly PING_INTERVAL = 30000; // 30 seconds - - private constructor() { + constructor(options: { + maxClients?: number; + pingInterval?: number; + cleanupInterval?: number; + maxConnectionAge?: number; + } = {}) { super(); + this.maxClients = options.maxClients || DEFAULT_MAX_CLIENTS; + this.pingInterval = options.pingInterval || DEFAULT_PING_INTERVAL; + this.cleanupInterval = options.cleanupInterval || DEFAULT_CLEANUP_INTERVAL; + this.maxConnectionAge = options.maxConnectionAge || DEFAULT_MAX_CONNECTION_AGE; + console.log('Initializing SSE Manager...'); - this.startMaintenanceInterval(); + this.startMaintenanceTasks(); } - private startMaintenanceInterval() { + private startMaintenanceTasks(): void { + // Send periodic pings to keep connections alive setInterval(() => { - this.performMaintenance(); - }, 60000); // Run every minute - } + this.clients.forEach(client => { + try { + client.send(JSON.stringify({ + type: 'ping', + timestamp: new Date().toISOString() + })); + client.lastPingAt = new Date(); + } catch (error) { + console.error(`Failed to ping client ${client.id}:`, error); + this.removeClient(client.id); + } + }); + }, this.pingInterval); - private performMaintenance() { - const now = Date.now(); + // Cleanup inactive or expired connections + setInterval(() => { + const now = Date.now(); + this.clients.forEach((client, clientId) => { + const connectionAge = now - client.connectedAt.getTime(); + const lastPingAge = client.lastPingAt ? now - client.lastPingAt.getTime() : 0; - // Check each client for timeouts and rate limits - for (const [clientId, client] of this.clients.entries()) { - // Remove inactive clients - if (now - client.lastPing > this.CLIENT_TIMEOUT) { - console.log(`Removing inactive client: ${clientId}`); - this.removeClient(clientId); - continue; - } - - // Reset rate limits if window has passed - if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) { - client.rateLimit.count = 0; - client.rateLimit.lastReset = now; - } - } - - // Log statistics - console.log(`Maintenance complete - Active clients: ${this.clients.size}`); + if (connectionAge > this.maxConnectionAge || lastPingAge > this.pingInterval * 2) { + console.log(`Removing inactive client ${clientId}`); + this.removeClient(clientId); + } + }); + }, this.cleanupInterval); } static getInstance(): SSEManager { @@ -75,47 +98,32 @@ export class SSEManager extends EventEmitter { return SSEManager.instance; } - addClient(client: { id: string; send: (data: string) => void }, token?: string): SSEClient | null { - // Check maximum client limit - if (this.clients.size >= this.MAX_CLIENTS) { - console.warn('Maximum client limit reached, rejecting new connection'); + addClient(client: Omit, token: string): SSEClient | null { + // Validate token + const validationResult = TokenManager.validateToken(token, client.ip); + if (!validationResult.valid) { + console.warn(`Invalid token for client ${client.id} from IP ${client.ip}: ${validationResult.error}`); return null; } - const now = Date.now(); - const sseClient: SSEClient = { - id: client.id, - send: client.send, - subscriptions: { - entities: new Set(), - events: new Set(), - domains: new Set() - }, - authenticated: this.validateToken(token), - rateLimit: { - count: 0, - lastReset: now - }, - lastPing: now, - connectionTime: now + // Check client limit + if (this.clients.size >= this.maxClients) { + console.warn(`Maximum client limit (${this.maxClients}) reached`); + return null; + } + + // Create new client with authentication and subscriptions + const newClient: SSEClient = { + ...client, + authenticated: true, + subscriptions: new Set(), + lastPingAt: new Date() }; - this.clients.set(client.id, sseClient); - console.log(`SSE client connected: ${client.id} (authenticated: ${sseClient.authenticated})`); + this.clients.set(client.id, newClient); + console.log(`New client ${client.id} connected from IP ${client.ip}`); - // Start ping interval for this client - this.startClientPing(client.id); - - // Send initial connection success message - this.sendToClient(sseClient, { - type: 'connection', - status: 'connected', - id: client.id, - authenticated: sseClient.authenticated, - timestamp: new Date().toISOString() - }); - - return sseClient; + return newClient; } private startClientPing(clientId: string) { @@ -130,20 +138,24 @@ export class SSEManager extends EventEmitter { type: 'ping', timestamp: new Date().toISOString() }); - }, this.PING_INTERVAL); + }, this.pingInterval); } removeClient(clientId: string) { if (this.clients.has(clientId)) { this.clients.delete(clientId); console.log(`SSE client disconnected: ${clientId}`); + this.emit('client_disconnected', { + clientId, + timestamp: new Date().toISOString() + }); } } subscribeToEntity(clientId: string, entityId: string) { const client = this.clients.get(clientId); if (client?.authenticated) { - client.subscriptions.entities.add(entityId); + client.subscriptions.add(`entity:${entityId}`); console.log(`Client ${clientId} subscribed to entity: ${entityId}`); // Send current state if available @@ -166,7 +178,7 @@ export class SSEManager extends EventEmitter { subscribeToDomain(clientId: string, domain: string) { const client = this.clients.get(clientId); if (client?.authenticated) { - client.subscriptions.domains.add(domain); + client.subscriptions.add(`domain:${domain}`); console.log(`Client ${clientId} subscribed to domain: ${domain}`); } } @@ -174,7 +186,7 @@ export class SSEManager extends EventEmitter { subscribeToEvent(clientId: string, eventType: string) { const client = this.clients.get(clientId); if (client?.authenticated) { - client.subscriptions.events.add(eventType); + client.subscriptions.add(`event:${eventType}`); console.log(`Client ${clientId} subscribed to event: ${eventType}`); } } @@ -203,9 +215,9 @@ export class SSEManager extends EventEmitter { if (!client.authenticated) continue; if ( - client.subscriptions.entities.has(entity.entity_id) || - client.subscriptions.domains.has(domain) || - client.subscriptions.events.has('state_changed') + client.subscriptions.has(`entity:${entity.entity_id}`) || + client.subscriptions.has(`domain:${domain}`) || + client.subscriptions.has('event:state_changed') ) { this.sendToClient(client, message); } @@ -228,7 +240,7 @@ export class SSEManager extends EventEmitter { for (const client of this.clients.values()) { if (!client.authenticated) continue; - if (client.subscriptions.events.has(event.event_type)) { + if (client.subscriptions.has(`event:${event.event_type}`)) { this.sendToClient(client, message); } } @@ -238,12 +250,12 @@ export class SSEManager extends EventEmitter { try { // Check rate limit const now = Date.now(); - if (now - client.rateLimit.lastReset > this.RATE_LIMIT_WINDOW) { + if (now - client.rateLimit.lastReset > this.cleanupInterval) { client.rateLimit.count = 0; client.rateLimit.lastReset = now; } - if (client.rateLimit.count >= this.RATE_LIMIT_MAX_REQUESTS) { + if (client.rateLimit.count >= 1000) { console.warn(`Rate limit exceeded for client ${client.id}`); this.sendToClient(client, { type: 'error', @@ -255,7 +267,7 @@ export class SSEManager extends EventEmitter { } client.rateLimit.count++; - client.lastPing = now; + client.lastPingAt = new Date(); client.send(JSON.stringify(data)); } catch (error) { console.error(`Error sending message to client ${client.id}:`, error); @@ -265,7 +277,8 @@ export class SSEManager extends EventEmitter { private validateToken(token?: string): boolean { if (!token) return false; - return TokenManager.validateToken(token); + const validationResult = TokenManager.validateToken(token); + return validationResult.valid; } // Utility methods @@ -326,63 +339,48 @@ export class SSEManager extends EventEmitter { for (const client of this.clients.values()) { if (!client.authenticated) continue; - if (client.subscriptions.events.has(eventType)) { + if (client.subscriptions.has(`event:${eventType}`) || + client.subscriptions.has(`entity:${eventType}`) || + client.subscriptions.has(`domain:${eventType.split('.')[0]}`)) { this.sendToClient(client, message); } } } // Add statistics methods - getStatistics() { + getStatistics(): { + totalClients: number; + authenticatedClients: number; + clientStats: ClientStats[]; + subscriptionStats: { [key: string]: number }; + } { const now = Date.now(); - const stats = { - total_clients: this.clients.size, - authenticated_clients: 0, - total_subscriptions: 0, - clients_by_connection_time: { - less_than_1m: 0, - less_than_5m: 0, - less_than_1h: 0, - more_than_1h: 0 - }, - total_entities_tracked: this.entityStates.size, - subscriptions: { - entities: new Set(), - events: new Set(), - domains: new Set() - } - }; + const clientStats: ClientStats[] = []; + const subscriptionCounts: { [key: string]: number } = {}; - for (const client of this.clients.values()) { - if (client.authenticated) stats.authenticated_clients++; + this.clients.forEach(client => { + // Collect client statistics + clientStats.push({ + id: client.id, + ip: client.ip, + connectedAt: client.connectedAt, + lastPingAt: client.lastPingAt, + subscriptionCount: client.subscriptions.size, + connectionDuration: now - client.connectedAt.getTime() + }); - // Count subscriptions - stats.total_subscriptions += - client.subscriptions.entities.size + - client.subscriptions.events.size + - client.subscriptions.domains.size; + // Count subscriptions by type + client.subscriptions.forEach(sub => { + const [type] = sub.split(':'); + subscriptionCounts[type] = (subscriptionCounts[type] || 0) + 1; + }); + }); - // Add to subscription sets - client.subscriptions.entities.forEach(entity => stats.subscriptions.entities.add(entity)); - client.subscriptions.events.forEach(event => stats.subscriptions.events.add(event)); - client.subscriptions.domains.forEach(domain => stats.subscriptions.domains.add(domain)); - - // Calculate connection duration - const connectionDuration = now - client.connectionTime; - if (connectionDuration < 60000) stats.clients_by_connection_time.less_than_1m++; - else if (connectionDuration < 300000) stats.clients_by_connection_time.less_than_5m++; - else if (connectionDuration < 3600000) stats.clients_by_connection_time.less_than_1h++; - else stats.clients_by_connection_time.more_than_1h++; - } - - // Convert Sets to Arrays for JSON serialization return { - ...stats, - subscriptions: { - entities: Array.from(stats.subscriptions.entities), - events: Array.from(stats.subscriptions.events), - domains: Array.from(stats.subscriptions.domains) - } + totalClients: this.clients.size, + authenticatedClients: Array.from(this.clients.values()).filter(c => c.authenticated).length, + clientStats, + subscriptionStats: subscriptionCounts }; } }