From 479e1918f57d01ee3711024b999cdea253711c36 Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 02:57:21 -0400 Subject: [PATCH] Implement rate limiting and enhance input validation for API endpoints --- web/server/server.js | 71 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/web/server/server.js b/web/server/server.js index 7c4715c..ea82826 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -1,6 +1,5 @@ import express from 'express'; import { WebSocketServer } from 'ws'; -import cors from 'cors'; import { createServer } from 'http'; import fs from 'fs'; import path from 'path'; @@ -18,7 +17,9 @@ const app = express(); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; -app.use(cors()); +// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin. +// If you need direct server access during dev, add: app.use(require('cors')()) +app.disable('x-powered-by'); app.use(express.json({ limit: '5mb' })); // ========== API Key Authentication ========== @@ -48,6 +49,40 @@ app.use((req, res, next) => { return requireAuth(req, res, next); }); +// ========== Rate Limiting (in-memory, no external dependencies) ========== + +function createRateLimiter(windowMs, max) { + const hits = new Map(); + setInterval(() => { + const cutoff = Date.now() - windowMs; + for (const [key, entry] of hits) { + if (entry.start < cutoff) hits.delete(key); + } + }, windowMs); + return (req, res, next) => { + const key = req.ip || req.socket.remoteAddress || 'unknown'; + const now = Date.now(); + const entry = hits.get(key); + if (!entry || now - entry.start > windowMs) { + hits.set(key, { start: now, count: 1 }); + return next(); + } + entry.count++; + if (entry.count > max) { + res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000))); + return res.status(429).json({ error: 'Too many requests — try again later' }); + } + return next(); + }; +} + +// 30 mutating requests per minute per IP (excludes bridge state updates) +const commandLimiter = createRateLimiter(60_000, 30); +app.use((req, res, next) => { + if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next(); + return commandLimiter(req, res, next); +}); + // ========== State ========== const webClients = new Set(); const bridgeClients = new Set(); @@ -330,13 +365,20 @@ app.post('/api/order', (req, res) => { if (!itemName || !amount) { return res.status(400).json({ error: 'Missing itemName or amount' }); } + if (typeof itemName !== 'string' || itemName.length > 200) { + return res.status(400).json({ error: 'Invalid itemName' }); + } + const parsedAmount = parseInt(amount); + if (!Number.isFinite(parsedAmount) || parsedAmount < 1 || parsedAmount > 100000) { + return res.status(400).json({ error: 'Invalid amount (1–100000)' }); + } const command = { type: 'command', action: 'order', commandId, itemName, - amount: parseInt(amount), + amount: parsedAmount, dropperName: dropperName || null, }; @@ -365,7 +407,12 @@ app.post('/api/dropper-nicknames', (req, res) => { const cached = checkIdempotent(commandId); if (cached) return res.json(cached); - if (!dropperName) return res.status(400).json({ error: 'Missing dropperName' }); + if (!dropperName || typeof dropperName !== 'string' || dropperName.length > 200) { + return res.status(400).json({ error: 'Missing or invalid dropperName' }); + } + if (nickname && String(nickname).length > 50) { + return res.status(400).json({ error: 'Nickname too long (max 50)' }); + } if (nickname && nickname.trim()) { dropperNicknames[dropperName] = nickname.trim(); @@ -427,7 +474,9 @@ app.post('/api/recipes/toggle', (req, res) => { const cached = checkIdempotent(commandId); if (cached) return res.json(cached); - if (!recipe) return res.status(400).json({ error: 'Missing recipe name' }); + if (!recipe || typeof recipe !== 'string' || recipe.length > 200) { + return res.status(400).json({ error: 'Missing or invalid recipe name' }); + } pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe }); const result = { success: true, commandId }; @@ -499,8 +548,12 @@ app.post('/api/craft', (req, res) => { if (cached) return res.json(cached); if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' }); + const parsedIdx = parseInt(recipeIdx); + if (!Number.isFinite(parsedIdx) || parsedIdx < 1 || parsedIdx > 1000) { + return res.status(400).json({ error: 'Invalid recipeIdx' }); + } - pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parseInt(recipeIdx) }); + pushCommandToBridge({ type: 'command', action: 'craft', commandId, recipeIdx: parsedIdx }); const result = { success: true, commandId }; recordCommand(commandId, result); console.log(`🔨 Craft request: recipe #${recipeIdx}`); @@ -524,8 +577,8 @@ app.post('/api/bridge/state', (req, res) => { } }); -// Bridge polls for pending commands -app.get('/api/bridge/commands', (req, res) => { +// Bridge polls for pending commands (auth required — contains operational data) +app.get('/api/bridge/commands', requireAuth, (req, res) => { try { const now = Date.now(); // Clear old commands (>30s) @@ -709,7 +762,7 @@ function updateStateFromBridge(data) { // ========== WebSocket Server ========== -const wss = new WebSocketServer({ noServer: true }); +const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 /* 1 MB */ }); console.log(`🚀 Inventory Manager Web Server starting...`); console.log(`📡 HTTP Server: http://localhost:${PORT}`);