From 138ba5aeb4ece2f12a6a31a10e9556e29c6dc462 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 27 Nov 2025 02:55:51 -0600 Subject: [PATCH] MAESTRO: Add rate limiting for web interface endpoints - Install @fastify/rate-limit package - Configure rate limiting with sensible defaults: - 100 requests/minute for GET endpoints - 30 requests/minute for POST endpoints (more restrictive) - Add RateLimitConfig interface for configuration - Apply rate limiting to all /web/* routes - Add /web/api/rate-limit endpoint to check current limits - Skip rate limiting for /health endpoint - Custom error response with retry-after information - Support for X-Forwarded-For header for proxied requests --- package-lock.json | 47 ++++++++++++++ package.json | 1 + src/main/web-server.ts | 135 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 175 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8fc70056..e797bac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", @@ -1203,6 +1204,43 @@ "fast-deep-equal": "^3.1.3" } }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/rate-limit/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/websocket": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-9.0.0.tgz", @@ -1396,6 +1434,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@malept/cross-spawn-promise": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz", diff --git a/package.json b/package.json index 0a5e9e82..aa8c59a7 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fastify/cors": "^8.5.0", + "@fastify/rate-limit": "^10.3.0", "@fastify/websocket": "^9.0.0", "@types/dompurify": "^3.0.5", "ansi-to-html": "^0.7.2", diff --git a/src/main/web-server.ts b/src/main/web-server.ts index 02918e7b..993724d5 100644 --- a/src/main/web-server.ts +++ b/src/main/web-server.ts @@ -1,6 +1,7 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import websocket from '@fastify/websocket'; +import rateLimit from '@fastify/rate-limit'; import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { WebSocket } from 'ws'; import crypto from 'crypto'; @@ -25,6 +26,18 @@ export interface WebAuthConfig { token: string | null; } +// Rate limiting configuration +export interface RateLimitConfig { + // Maximum requests per time window + max: number; + // Time window in milliseconds + timeWindow: number; + // Maximum requests for POST endpoints (typically lower) + maxPost: number; + // Enable/disable rate limiting + enabled: boolean; +} + /** * WebServer - HTTP and WebSocket server for remote access * @@ -81,6 +94,14 @@ export interface WebTheme { // Callback type for fetching current theme export type GetThemeCallback = () => WebTheme | null; +// Default rate limit configuration +const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = { + max: 100, // 100 requests per minute for GET endpoints + timeWindow: 60000, // 1 minute in milliseconds + maxPost: 30, // 30 requests per minute for POST endpoints (more restrictive) + enabled: true, +}; + export class WebServer { private server: FastifyInstance; private port: number; @@ -88,6 +109,7 @@ export class WebServer { private webClients: Map = new Map(); private clientIdCounter: number = 0; private authConfig: WebAuthConfig = { enabled: false, token: null }; + private rateLimitConfig: RateLimitConfig = { ...DEFAULT_RATE_LIMIT_CONFIG }; private getSessionsCallback: GetSessionsCallback | null = null; private getThemeCallback: GetThemeCallback | null = null; @@ -134,6 +156,21 @@ export class WebServer { return { ...this.authConfig }; } + /** + * Set the rate limiting configuration + */ + setRateLimitConfig(config: Partial) { + this.rateLimitConfig = { ...this.rateLimitConfig, ...config }; + console.log(`Web server rate limiting ${this.rateLimitConfig.enabled ? 'enabled' : 'disabled'} (max: ${this.rateLimitConfig.max}/min, maxPost: ${this.rateLimitConfig.maxPost}/min)`); + } + + /** + * Get the current rate limiting configuration + */ + getRateLimitConfig(): RateLimitConfig { + return { ...this.rateLimitConfig }; + } + /** * Generate a new random authentication token */ @@ -198,6 +235,44 @@ export class WebServer { // Enable WebSocket support await this.server.register(websocket); + + // Enable rate limiting for web interface endpoints to prevent abuse + // Rate limiting is applied globally but can be overridden per-route + await this.server.register(rateLimit, { + global: false, // Don't apply to all routes by default (we'll apply selectively) + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + // Custom error response + errorResponseBuilder: ( + _request: FastifyRequest, + context: { statusCode: number; ban: boolean; after: string; max: number; ttl: number } + ) => { + return { + statusCode: 429, + error: 'Too Many Requests', + message: `Rate limit exceeded. You can make ${context.max} requests per ${context.after}. Try again later.`, + retryAfter: context.after, + }; + }, + // Allow list function to skip rate limiting for certain requests + allowList: (request: FastifyRequest) => { + // Skip rate limiting if disabled + if (!this.rateLimitConfig.enabled) return true; + // Allow health checks without rate limiting + if (request.url === '/health') return true; + return false; + }, + // Use IP address as the rate limit key + keyGenerator: (request: FastifyRequest) => { + // Use X-Forwarded-For if available (for proxied requests), otherwise use IP + const forwarded = request.headers['x-forwarded-for']; + if (forwarded) { + const ip = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0].trim(); + return ip; + } + return request.ip; + }, + }); } private setupRoutes() { @@ -267,10 +342,32 @@ export class WebServer { * - /web/mobile - Mobile web interface entry point * - /web/api/* - REST API endpoints for web clients * - /ws/web - WebSocket endpoint for real-time updates to web clients + * + * Rate limiting is applied to all web interface endpoints to prevent abuse. */ private setupWebInterfaceRoutes() { + // Rate limit configuration for GET endpoints + const getRateLimitConfig = { + config: { + rateLimit: { + max: this.rateLimitConfig.max, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }; + + // Rate limit configuration for POST endpoints (more restrictive) + const postRateLimitConfig = { + config: { + rateLimit: { + max: this.rateLimitConfig.maxPost, + timeWindow: this.rateLimitConfig.timeWindow, + }, + }, + }; + // Web interface root - returns info about available interfaces - this.server.get('/web', async () => { + this.server.get('/web', getRateLimitConfig, async () => { return { name: 'Maestro Web Interface', version: '1.0.0', @@ -285,7 +382,7 @@ export class WebServer { }); // Desktop web interface entry point (placeholder) - this.server.get('/web/desktop', async () => { + this.server.get('/web/desktop', getRateLimitConfig, async () => { return { message: 'Desktop web interface - Coming soon', description: 'Full-featured collaborative interface for hackathons/team coding', @@ -293,7 +390,7 @@ export class WebServer { }); // Desktop web interface with wildcard for client-side routing - this.server.get('/web/desktop/*', async () => { + this.server.get('/web/desktop/*', getRateLimitConfig, async () => { return { message: 'Desktop web interface - Coming soon', description: 'Full-featured collaborative interface for hackathons/team coding', @@ -301,7 +398,7 @@ export class WebServer { }); // Mobile web interface entry point (placeholder) - this.server.get('/web/mobile', async () => { + this.server.get('/web/mobile', getRateLimitConfig, async () => { return { message: 'Mobile web interface - Coming soon', description: 'Lightweight remote control for sending commands from your phone', @@ -309,7 +406,7 @@ export class WebServer { }); // Mobile web interface with wildcard for client-side routing - this.server.get('/web/mobile/*', async () => { + this.server.get('/web/mobile/*', getRateLimitConfig, async () => { return { message: 'Mobile web interface - Coming soon', description: 'Lightweight remote control for sending commands from your phone', @@ -317,13 +414,34 @@ export class WebServer { }); // Web API namespace root - this.server.get('/web/api', async () => { + this.server.get('/web/api', getRateLimitConfig, async () => { return { name: 'Maestro Web API', version: '1.0.0', endpoints: { sessions: '/web/api/sessions', theme: '/web/api/theme', + rateLimit: '/web/api/rate-limit', + }, + timestamp: Date.now(), + }; + }); + + // Rate limit status endpoint - allows clients to check current limits + this.server.get('/web/api/rate-limit', getRateLimitConfig, async () => { + return { + enabled: this.rateLimitConfig.enabled, + limits: { + get: { + max: this.rateLimitConfig.max, + timeWindowMs: this.rateLimitConfig.timeWindow, + timeWindowDescription: `${this.rateLimitConfig.timeWindow / 1000} seconds`, + }, + post: { + max: this.rateLimitConfig.maxPost, + timeWindowMs: this.rateLimitConfig.timeWindow, + timeWindowDescription: `${this.rateLimitConfig.timeWindow / 1000} seconds`, + }, }, timestamp: Date.now(), }; @@ -477,7 +595,7 @@ export class WebServer { }); // Authentication status endpoint - allows checking if auth is enabled - this.server.get('/web/api/auth/status', async () => { + this.server.get('/web/api/auth/status', getRateLimitConfig, async () => { return { enabled: this.authConfig.enabled, timestamp: Date.now(), @@ -485,7 +603,8 @@ export class WebServer { }); // Authentication verification endpoint - checks if a token is valid - this.server.post('/web/api/auth/verify', async (request) => { + // Uses more restrictive POST rate limit to prevent brute force attacks + this.server.post('/web/api/auth/verify', postRateLimitConfig, async (request) => { const body = request.body as { token?: string } | undefined; const token = body?.token;