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
This commit is contained in:
Pedram Amini
2025-11-27 02:55:51 -06:00
parent 55761ee5db
commit 138ba5aeb4
3 changed files with 175 additions and 8 deletions

47
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, WebClient> = 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<RateLimitConfig>) {
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;