Implement rate limiting and enhance input validation for API endpoints
This commit is contained in:
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user