Implement rate limiting and enhance input validation for API endpoints

This commit is contained in:
MayaTheShy
2026-03-22 02:57:21 -04:00
parent 9d5fceee5c
commit 479e1918f5

View File

@@ -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 (1100000)' });
}
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}`);