Implement rate limiting and enhance input validation for API endpoints
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import cors from 'cors';
|
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -18,7 +17,9 @@ const app = express();
|
|||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
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' }));
|
app.use(express.json({ limit: '5mb' }));
|
||||||
|
|
||||||
// ========== API Key Authentication ==========
|
// ========== API Key Authentication ==========
|
||||||
@@ -48,6 +49,40 @@ app.use((req, res, next) => {
|
|||||||
return requireAuth(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 ==========
|
// ========== State ==========
|
||||||
const webClients = new Set();
|
const webClients = new Set();
|
||||||
const bridgeClients = new Set();
|
const bridgeClients = new Set();
|
||||||
@@ -330,13 +365,20 @@ app.post('/api/order', (req, res) => {
|
|||||||
if (!itemName || !amount) {
|
if (!itemName || !amount) {
|
||||||
return res.status(400).json({ error: 'Missing itemName or 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 = {
|
const command = {
|
||||||
type: 'command',
|
type: 'command',
|
||||||
action: 'order',
|
action: 'order',
|
||||||
commandId,
|
commandId,
|
||||||
itemName,
|
itemName,
|
||||||
amount: parseInt(amount),
|
amount: parsedAmount,
|
||||||
dropperName: dropperName || null,
|
dropperName: dropperName || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -365,7 +407,12 @@ app.post('/api/dropper-nicknames', (req, res) => {
|
|||||||
const cached = checkIdempotent(commandId);
|
const cached = checkIdempotent(commandId);
|
||||||
if (cached) return res.json(cached);
|
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()) {
|
if (nickname && nickname.trim()) {
|
||||||
dropperNicknames[dropperName] = nickname.trim();
|
dropperNicknames[dropperName] = nickname.trim();
|
||||||
@@ -427,7 +474,9 @@ app.post('/api/recipes/toggle', (req, res) => {
|
|||||||
const cached = checkIdempotent(commandId);
|
const cached = checkIdempotent(commandId);
|
||||||
if (cached) return res.json(cached);
|
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 });
|
pushCommandToBridge({ type: 'command', action: 'toggle_recipe', commandId, recipe });
|
||||||
const result = { success: true, commandId };
|
const result = { success: true, commandId };
|
||||||
@@ -499,8 +548,12 @@ app.post('/api/craft', (req, res) => {
|
|||||||
if (cached) return res.json(cached);
|
if (cached) return res.json(cached);
|
||||||
|
|
||||||
if (recipeIdx === undefined) return res.status(400).json({ error: 'Missing recipeIdx' });
|
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 };
|
const result = { success: true, commandId };
|
||||||
recordCommand(commandId, result);
|
recordCommand(commandId, result);
|
||||||
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
console.log(`🔨 Craft request: recipe #${recipeIdx}`);
|
||||||
@@ -524,8 +577,8 @@ app.post('/api/bridge/state', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bridge polls for pending commands
|
// Bridge polls for pending commands (auth required — contains operational data)
|
||||||
app.get('/api/bridge/commands', (req, res) => {
|
app.get('/api/bridge/commands', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// Clear old commands (>30s)
|
// Clear old commands (>30s)
|
||||||
@@ -709,7 +762,7 @@ function updateStateFromBridge(data) {
|
|||||||
|
|
||||||
// ========== WebSocket Server ==========
|
// ========== 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(`🚀 Inventory Manager Web Server starting...`);
|
||||||
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
console.log(`📡 HTTP Server: http://localhost:${PORT}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user